From 3d3192e43f7fd75084c8ffd36181bed48f52765a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 29 Sep 2026 21:29:23 +0200 Subject: [PATCH 1/2] Allow bounded Viewer signing-window reuse --- .changeset/quiet-images-reuse.md | 8 +++ docs/prompts/2026-09-29-cdn-rotation.md | 30 ++++++++ packages/img/docs/react-storage.md | 29 ++++++-- packages/img/src/server.ts | 16 ++++- packages/img/test/storage-route.test.tsx | 88 ++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 .changeset/quiet-images-reuse.md create mode 100644 docs/prompts/2026-09-29-cdn-rotation.md diff --git a/.changeset/quiet-images-reuse.md b/.changeset/quiet-images-reuse.md new file mode 100644 index 00000000..326d84db --- /dev/null +++ b/.changeset/quiet-images-reuse.md @@ -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. diff --git a/docs/prompts/2026-09-29-cdn-rotation.md b/docs/prompts/2026-09-29-cdn-rotation.md new file mode 100644 index 00000000..d10d7508 --- /dev/null +++ b/docs/prompts/2026-09-29-cdn-rotation.md @@ -0,0 +1,30 @@ +# 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). +- [ ] Local defensive security review and packed browser fixture. +- [ ] Exact-head CI. + +## 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. diff --git a/packages/img/docs/react-storage.md b/packages/img/docs/react-storage.md index d87bce76..5bafc7e6 100644 --- a/packages/img/docs/react-storage.md +++ b/packages/img/docs/react-storage.md @@ -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. diff --git a/packages/img/src/server.ts b/packages/img/src/server.ts index e173341d..15be95a8 100644 --- a/packages/img/src/server.ts +++ b/packages/img/src/server.ts @@ -36,7 +36,13 @@ export interface StorageRouteOptions { input: StorageAuthorizationRequest, ) => StorageAssetReceipt | null | Promise 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. */ @@ -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 { if (request.method !== 'GET' && request.method !== 'HEAD') @@ -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({ diff --git a/packages/img/test/storage-route.test.tsx b/packages/img/test/storage-route.test.tsx index eec2f308..b3dca76e 100644 --- a/packages/img/test/storage-route.test.tsx +++ b/packages/img/test/storage-route.test.tsx @@ -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', From 6206ca851835a926d868dcdb4c76694af6d045d8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 29 Sep 2026 21:33:45 +0200 Subject: [PATCH 2/2] Record review and exact-commit validation evidence --- docs/prompts/2026-09-29-cdn-rotation.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/prompts/2026-09-29-cdn-rotation.md b/docs/prompts/2026-09-29-cdn-rotation.md index d10d7508..c8a54c1d 100644 --- a/docs/prompts/2026-09-29-cdn-rotation.md +++ b/docs/prompts/2026-09-29-cdn-rotation.md @@ -19,8 +19,15 @@ first cold transform. The runtime-neutral route is the only changed delivery API - [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). -- [ ] Local defensive security review and packed browser fixture. -- [ ] Exact-head CI. +- [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: . No tests or gates are disabled. ## Consumer follow-up