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
8 changes: 8 additions & 0 deletions .changeset/quiet-images-reuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@transloadit/viewer': patch
---

Add an optional `rotationIntervalMs` to `createStorageRoute` for longer reuse of signed CDN URLs.
The default interval, maximum grant lifetime, and per-request authorization remain unchanged.
The interval cannot exceed half the lifetime, keeping newly issued URLs usable for at least
half their maximum lifetime. Viewer remains alpha.
37 changes: 37 additions & 0 deletions docs/prompts/2026-09-29-cdn-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Private CDN signing-window reuse

## Why

The production Convex dogfood measured cold private renditions at 1.91–2.60 seconds, while
repeating the exact signed URL hit the CDN in 25–135 ms. The default minute boundary creates
another cache key. This narrow change permits a longer window without extending the grant's
maximum lifetime or caching an application's authorization result. It does not accelerate the
first cold transform. The runtime-neutral route is the only changed delivery API.

## Contracts and verification

- [x] Preserve the default interval and five-minute maximum lifetime.
- [x] Permit an explicit positive integer interval no greater than half the lifetime.
- [x] Red first: four longer-window cases and seven invalid configurations fail on main.
- [x] All 101 route tests and all 468 Viewer tests pass after implementation.
- [x] Cover preview, crop, original and download, GET/HEAD, clock boundaries and same-window
revocation; retain per-request authorization and `private, no-store`.
- [x] Document the remaining-validity tradeoff; add a Viewer-only alpha patch changeset.
- [x] Council review: no remaining issues; retain the unchanged Node/Next defaults deliberately.
- [x] Full repository `yarn verify:full` passes (existing lint warnings remain).
- [x] Local defensive Opus security review: PASS, no supported P0–P3 findings. This is an
in-memory API review, not a live CDN security claim. Rebuilt/reran all 468 Viewer tests on
commit `3d3192e`; retained its SHA and source/build checksums with the machine-readable result.
- [x] Reconcile the local packed-fixture attempt: stale npm metadata was fixed with an isolated
fresh cache; install, seed and consumer type checks passed. Firefox extraction then stalled
for ten minutes. Stopped only the owned installer processes; no browser PASS is claimed.

Merge/release gate: the unchanged packed browser fixture and all other checks must pass on the
exact PR head: <https://github.com/transloadit/node-sdk/pull/527>. No tests or gates are disabled.

## Consumer follow-up

After the normal Changesets alpha release, the Convex demo can explicitly choose a 150-second
window and 300-second maximum lifetime. Verify repeat-view reuse and denial/expiry again after
deployment. Keep the default unchanged for other consumers. API2 rendition reuse across signing
windows needs a separate design; do not remove authentication or expiry from Bunny's cache key.
29 changes: 25 additions & 4 deletions packages/img/docs/react-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,31 @@ allow 31 entries; at most eight crop profiles may use ratios from 1/8 through 8.
never exceed 8000. Unknown/duplicate parameters and unlisted candidates are denied. Templates,
origin, quality, background, dimensions, and crop ratios never come from request data.

`lifetimeMs` defaults to five minutes (1000 through 172800000). CDN signatures rotate at most once
a minute, bounded by half the lifetime, preserving at least half a lifetime on new URLs. Bunny
still keys on the full query; default Built-in parameters retain the existing canonical spelling.
Route URLs never expire and can request fresh authorization from long-open pages.
`lifetimeMs` is the maximum CDN grant lifetime in milliseconds, defaulting to five minutes
(1000 through 172800000). By default, signatures rotate every minute, or every half-lifetime
when that is shorter, rounded down to whole milliseconds. Route URLs never expire and can
request fresh authorization from long-open pages.

To reuse CDN entries longer without extending the maximum lifetime, set `rotationIntervalMs`:

```ts
const { GET, HEAD } = createStorageRoute({
workspace, authKey, authSecret, authorizeAsset,
lifetimeMs: 5 * 60_000,
rotationIntervalMs: 150_000,
})
```

The interval must be a positive integer no greater than half of `lifetimeMs`. Signing windows
align to the clock, not a visitor's session. A newly issued URL has more than
`lifetimeMs - rotationIntervalMs` and at most `lifetimeMs` remaining: the example gives
150–300 seconds, compared with 240–300 seconds at the default interval. There are 24 signing
windows per hour instead of 60; identical renditions reuse a URL within each window when
credentials and metadata stay unchanged. This can improve repeat-view cache reuse, not the first
cold transform. Every request still runs `authorizeAsset`; authorization results and redirects
are never cached by the route.
Bunny still keys on the full query, including authentication and expiry. Default Built-in
parameters retain the existing canonical spelling.

Every response is `private, no-store`. Redirects are 307 with an empty body; denied/malformed
requests share a safe 404, unsupported methods get 405, and internal failures get a sanitized 500.
Expand Down
16 changes: 14 additions & 2 deletions packages/img/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export interface StorageRouteOptions {
input: StorageAuthorizationRequest,
) => StorageAssetReceipt | null | Promise<StorageAssetReceipt | null>
policy?: StorageRenditionPolicy
/** Maximum CDN grant lifetime in milliseconds; defaults to five minutes. */
lifetimeMs?: number
/**
* Signing window in milliseconds, at most half the lifetime.
* Defaults to min(60000, lifetimeMs / 2), rounded down.
*/
rotationIntervalMs?: number
/** Trusted CDN override for local testing; never derive from the incoming request. */
baseUrl?: string
/** App-controlled development opt-in. Keep false in production. Logs only allowed variants. */
Expand Down Expand Up @@ -143,6 +149,13 @@ export function createStorageRoute(options: StorageRouteOptions): StorageRoute {
const lifetimeMs = options.lifetimeMs ?? 5 * 60_000
if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1000 || lifetimeMs > 48 * 3600_000)
throw new TypeError('lifetimeMs must be an integer from 1000 through 172800000')
const rotationMs = options.rotationIntervalMs ?? Math.min(60_000, Math.floor(lifetimeMs / 2))
if (
!Number.isSafeInteger(rotationMs) ||
rotationMs < 1 ||
rotationMs > Math.floor(lifetimeMs / 2)
)
throw new TypeError('rotationIntervalMs must be an integer from 1 through half of lifetimeMs')
const policy = snapshotStoragePolicy(options.policy)
async function handle(request: Request): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD')
Expand Down Expand Up @@ -207,8 +220,7 @@ export function createStorageRoute(options: StorageRouteOptions): StorageRoute {
// Receipt validation failures and disallowed shapes share the absent-asset response.
return respond(request, 404, 'Not found')
}
// Match the existing CDN rotation; Bunny keys on the full query including auth and expiry.
const rotationMs = Math.min(60_000, Math.floor(lifetimeMs / 2))
// Bunny keys on the full query; reuse signatures, never the request's authorization result.
const expiresAt = Math.floor(Date.now() / rotationMs) * rotationMs + lifetimeMs
failureStage = 'signing'
const location = await getSignedSmartCdnUrl({
Expand Down
88 changes: 88 additions & 0 deletions packages/img/test/storage-route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,94 @@ test.each([
expect((await handler.HEAD(request(url, 'HEAD'))).headers.get('location')).toBe(rotated)
})

test.each([
'preview',
'crop',
'original',
'download',
] as const)('%s can reuse a longer signing window without caching authorization', async (action) => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'))
const authorizeAsset = vi.fn((): StorageAssetReceipt | null => receipt)
const policy = { crops: { square: { aspectRatio: 1 } } }
const handler = createStorageRoute({
...credentials,
lifetimeMs: 300_000,
rotationIntervalMs: 150_000,
policy,
authorizeAsset,
})
const url =
action === 'preview' || action === 'crop'
? preview(receipt, { policy, crop: action === 'crop' ? 'square' : undefined })
: getStorageAssetHref(receipt, { action })
const before = await handler.GET(request(url))
const target = before.headers.get('location') ?? ''
expect(before.status).toBe(307)
expect(before.headers.get('cache-control')).toBe('private, no-store')
expect(parseSmartCdnUrl(target).auth?.expiresAt).toBe(Date.now() + 300_000)

vi.advanceTimersByTime(60_000)
expect((await handler.GET(request(url))).headers.get('location')).toBe(target)
vi.advanceTimersByTime(89_999)
const head = await handler.HEAD(request(url, 'HEAD'))
expect(head.headers.get('location')).toBe(target)
expect(head.headers.get('cache-control')).toBe('private, no-store')
expect(await head.text()).toBe('')
expect(parseSmartCdnUrl(target).auth?.expiresAt).toBe(Date.now() + 150_001)

vi.advanceTimersByTime(1)
const rotated = (await handler.GET(request(url))).headers.get('location') ?? ''
expect(rotated).not.toBe(target)
expect(parseSmartCdnUrl(rotated).auth?.expiresAt).toBe(Date.now() + 300_000)
expect((await handler.HEAD(request(url, 'HEAD'))).headers.get('location')).toBe(rotated)
expect(authorizeAsset).toHaveBeenCalledTimes(5)

authorizeAsset.mockReturnValue(null)
const denied = await handler.GET(request(url))
expect(denied.status).toBe(404)
expect(denied.headers.has('location')).toBe(false)
expect(denied.headers.get('cache-control')).toBe('private, no-store')
const deniedHead = await handler.HEAD(request(url, 'HEAD'))
expect(deniedHead.status).toBe(404)
expect(deniedHead.headers.has('location')).toBe(false)
expect(await deniedHead.text()).toBe('')
expect(authorizeAsset).toHaveBeenCalledTimes(7)
})

test('the default five-minute grant still rotates after one minute', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'))
const handler = route()
const before = (await handler.GET(request())).headers.get('location') ?? ''
expect(parseSmartCdnUrl(before).auth?.expiresAt).toBe(Date.now() + 300_000)
vi.advanceTimersByTime(59_999)
expect((await handler.GET(request())).headers.get('location')).toBe(before)
vi.advanceTimersByTime(1)
const after = (await handler.GET(request())).headers.get('location') ?? ''
expect(after).not.toBe(before)
expect(parseSmartCdnUrl(after).auth?.expiresAt).toBe(Date.now() + 300_000)
})

test.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER,
150_001,
])('rejects an unsafe rotationIntervalMs of %s at construction', (rotationIntervalMs) => {
expect(() =>
createStorageRoute({
...credentials,
lifetimeMs: 300_000,
rotationIntervalMs,
authorizeAsset: () => receipt,
}),
).toThrow('rotationIntervalMs must be an integer from 1 through half of lifetimeMs')
})

test.each([
'/app.v2/api/media',
'/~user/api/media',
Expand Down
Loading