Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/share-smart-cdn-image-policy.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@transloadit/utils": patch
---

Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make
the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent
upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters
can reuse the exported format and width normalization instead of copying those limits.

Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry
values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently
with the exported TypeScript contract instead of being treated as an omitted format.

Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated
signature replaces, which could otherwise produce an unverifiable URL.
Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain
unambiguously unsigned and round-trip through the parser.

Require callers to select a trusted workspace Template explicitly. The helper no longer defaults
to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl`
because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level
replacements affect only the newly introduced, not-yet-adopted image-candidate API.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,20 @@ jobs:
- run: corepack yarn changeset:version:release
- run: corepack yarn release:pack:dry-run

img-next-fixture:
name: Image package Next.js fixture
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
cache: npm
cache-dependency-path: scripts/fixtures/img-next/package-lock.json
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture

unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server.

## Packages

- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See
`packages/img/README.md`.
- `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`.
- `transloadit` — Stable unscoped package (built from `@transloadit/node`).
- `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`.
Expand Down
199 changes: 199 additions & 0 deletions docs/prompts/2026-08-31-img-delivery-dx.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
# `@transloadit/img` delivery and DX completion

## Why

The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be
rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model
details that application authors should not need to understand. Private Storage previews also need
two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized
redirects for stricter revocation and cache-stable HTML.

An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an
immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets.
Disabling signature enforcement on the current `serve-image` Built-in would create an open
transformation and billing proxy.

## Decisions

- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct
delivery and `{ Image, storageRoute }` when an authorization route is configured.
- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`.
- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove
duplicate source dimensions and the aspect-ratio exception API.
- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced
optional override and make `sizes` optional but strongly recommended.
- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum
lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived
server factory cannot emit expired URLs while repeated renders stay cache-friendly.
- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart
CDN. A Next server never proxies their bytes.
- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated
per request and support normal lazy loading; document the expiry tradeoff.
- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its
expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path
and transform, so private names stay out of markup and clients cannot mutate them into
arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds
with a non-cacheable redirect; image bytes never pass through Next.
- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes,
unsupported transforms, and failed authorization without exposing private object details.
- [x] Keep explicit AVIF/WebP `<picture>` sources and a JPEG fallback. Do not use `Accept`-driven
`format:auto` until CDN cache keys normalize or vary on the selected format.
- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time
direct signing, route authorization, redirects, no secret leakage, and correct byte paths.
- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation
behavior. Record numbers without flaky wall-clock CI thresholds.
- [x] Rewrite the README around the short happy path, then explain the two private delivery modes
and their security/performance tradeoff progressively.
- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and
council review.

## API2 follow-up (read-only in this slice)

- [x] Inspect `~/code/api2-clone-3` without modifying it.
- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform,
output, abuse, and billing limits enforced before imports or processing start.
- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache
key normalization for CloudFront and Bunny.
- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed
before exposing a public `next/image` loader.

### Read-only findings

- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested
dimensions, quality, strategy, and format, but does not require signatures. Disabling a
workspace's signature requirement would therefore expose an origin-fetching transformation and
billing proxy.
- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set
`requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an
Assembly is reserved, but Built-in definitions have no delivery-profile concept yet.
- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the
cache key. Its viewer-request function validates known keys before cache lookup, but currently
passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to
a cache behavior serving private shared entries.
- Bunny currently varies on hostname and every query parameter. This preserves authorization
isolation, but every newly signed URL creates another cache entry.
- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts
now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also
supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone
security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path
still needs a custom validator or an API2 token mint.
- API2 issue #7998 already defines the right immutable DAM identity:
`/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and
responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety.

### Recommended public delivery profile

Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`:

1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin
set), an allowed relative path prefix, and one pinned certified Template version. The public URL
accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute
origin or caller-selected Template.
2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies,
maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget.
Redirects should be disabled in v1; if added, every hop must be revalidated against the same
origin/path policy with DNS-rebinding and private-network protections.
3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly
reservation or Robot work. The cache identity includes host/workspace, profile version, canonical
source path, source version, transform, and selected format.
4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a
profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer
Template overrides in the public profile. Trusted signed integrations may keep their override.
5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2
request validation cannot by itself bound decompression or intermediate allocation.

This produces the eventual secretless loader contract:

```ts
createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' })
```

The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be
enabled after the edge converts browser capability into an explicit selected-format cache dimension.
Until then, `@transloadit/img`'s AVIF/WebP `<picture>` sources remain safer and more predictable.

### Recommended private edge authorization

Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current
mixed public path:

1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile,
exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`.
Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs.
2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens,
unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid
signatures fail closed before cache lookup.
3. Only after successful validation may auth token and expiry be excluded from the shared cache key.
Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain.
Strip viewer auth before origin/logging where possible and add an origin-only edge attestation;
API2 must reject direct requests and spoofable client headers.
4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny
now has equivalent pre-cache hooks in public preview, so build the validator around shared golden
vectors and test it there next. Do not change the live wildcard or query variation until provider
tests prove invalid/expired/unsigned requests cannot reuse a warm object.
5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and
native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's
desired per-workspace app-issued key contract on the current shared distribution, so keep the
capability grammar provider-neutral.

### Required verification and rollout

- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny
Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay,
expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path.
- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus
origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries.
- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an
absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid
tokens share one cached representation and all content-changing fields split the cache.
- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review.
Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK
patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs,
marketing, Astro/framework adapters, and Uppy/DAM integration where relevant.

## Progress and evidence

### Storage-only cutover

The final pre-publication source contract is narrower than the initial prototype:

- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses
the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and
every arbitrary HTTP-origin policy were removed before publication.
- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level
`template` override. Template selection is never controlled by an individual image.
- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned
Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory.
- Content can first move to a signed workspace Template with a literal Transloadit origin, then move
canonical originals to Transloadit Storage without changing the browser rendering model.
- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed.
Removing the Built-in first would break the currently published utils default and Content's live
signed candidates.

- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app`
workspace, and organization-wide GitHub code search found no external repository consumer. The
bounded production SQL audit was not bypassed after SSH reported a changed host key.
- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist,
long-lived public expiry cache, source discriminator, and public fixture route. Direct and
authorized-redirect Storage delivery remain independently covered.

- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments
were open before this completion slice started.
- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup
under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty
non-cacheable redirects, and absence of secrets/private paths in browser-visible build output.
- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli
bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476
Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default.
- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently
verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit
replay regression now covers secret, workspace, Template, route, and `basePath` binding.
- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and
that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now
uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed
Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and
twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only
cutover above removes that route and cache policy.
- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing
untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL
Transform admission, and DAM/resource-limit issues informed the recommendation above.
11 changes: 11 additions & 0 deletions knip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,17 @@ const config: KnipConfig = {
interface: true,
},
workspaces: {
'packages/img': {
entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'],
project: ['{src,test}/**/*.{ts,tsx}'],
ignore: [...sharedIgnore],
ignoreDependencies: [
// Knip cannot infer the Vitest environment dependency from its file pragma.
'happy-dom',
// Tooling lives at the repo root in this monorepo.
'vitest',
],
},
'packages/node': {
entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'],
project: ['{src,test}/**/*.{ts,tsx,js,jsx}'],
Expand Down
Loading