diff --git a/.changeset/public-image-candidates.md b/.changeset/public-image-candidates.md new file mode 100644 index 00000000..59163026 --- /dev/null +++ b/.changeset/public-image-candidates.md @@ -0,0 +1,9 @@ +--- +"@transloadit/utils": minor +--- + +Add `getSmartCdnImageCandidates` next to the signed candidate builder on the Node entry point. +Share candidate validation and URL encoding while omitting signatures and expiry for genuinely +public delivery. Callers can use receipt-derived version tags for stable, immutable cache keys. +Include a proportional (rounded-up) fit height, or the backend limit without source metadata, +so a Template's default height does not silently invalidate the width descriptors. diff --git a/.changeset/storage-image-prefixes.md b/.changeset/storage-image-prefixes.md new file mode 100644 index 00000000..ea961b80 --- /dev/null +++ b/.changeset/storage-image-prefixes.md @@ -0,0 +1,9 @@ +--- +"@transloadit/utils": minor +"@transloadit/node": patch +"transloadit": patch +--- + +Share Storage path and directory-prefix validation between image integrations and CLI scaffolds. +Keep generated image recipes scoped, preserve relative imports for hidden receipt catalogs, and +sign Storage listing requests against the endpoint belonging to the selected key credentials. diff --git a/.changeset/storage-image-receipts.md b/.changeset/storage-image-receipts.md new file mode 100644 index 00000000..9763e5fc --- /dev/null +++ b/.changeset/storage-image-receipts.md @@ -0,0 +1,78 @@ +--- +"@transloadit/node": minor +"transloadit": minor +"@transloadit/mcp-server": patch +--- + +Release gate: publish this minor release only after transloadit/api2#9057 and +transloadit/content#5973 are deployed. Device login, public delivery and safe CLI-key revocation +depend on that coordinated backend/Console rollout. The image package remains private dogfood. +At publication, switch preview-branch documentation links to their then-merged main locations. +Require Node 20.10.0+ for JSON import attributes and composed AbortSignal cancellation. Logout +only forgets imported and legacy application keys unless revocation is explicitly requested +with `--revoke`. + +Add `client.storeImage(filePath, { path })` for one original Storage image without overwriting. +Stream the input checksum and verify the completed receipt's path, asset ID, stored bytes and +EXIF-oriented display dimensions. Community-plan transformations may change the stored size/MD5; +return authoritative result metadata and expose the input comparison through `onReceipt`. +The CLI warns about changed bytes, saves the receipt and adds bounded debug diagnostics. +Return typed metadata suitable for saving and rendering without another lookup. Preserve Assembly +upload progress, cancellation and errors; receipt validation after a write is not a rollback. + +Add `transloadit storage store ` using the CLI's existing +Assembly credentials. Atomically append keyed receipts, preserve previous data on failures and +reject concurrent writers, then print a ready-to-render StorageImage snippet. +Add `storage store --hashed` for content-addressed filenames: eight MD5 hex digits before the +extension, with catalog keys, generated types and JSX following the stored path. Retain the local +filename as `source`; reuse matching full-checksum/size receipts without uploading. Never overwrite +a hash conflict. Keep production-unavailable S3 recovery commands in the image reference only. +When receipt validation fails after writing, print the destination and Assembly ID for recovery. +Point to list/sync, not overwrite or another upload. Report pending browser approval every minute. +Document npm-first onboarding, browser signup and free-plan watermark behavior. +Keep receipts-file filesystem errors distinct from JSON validation failures, with the file path. +Retain a completed temporary catalog on local replacement failures, print the verified receipt, +and preserve an existing catalog's permissions. + +Add `getStoredImageReceipt({ assemblyId, expected })` to recover the same verified metadata after +a trusted upload notification or a local file error. Add explicit `store --overwrite` and +read-scoped `storage ls `; overwriting is never implicit. + +Add `storage receipts sync --receipts images.json` to recover rendering metadata from +signed, paginated List + HEAD reads without an Assembly or original download. Rebuild +path/width/height with an optional compatible MD5 ETag, without inventing an asset ID. Share +atomic receipt-file writes and credential-bound endpoint resolution with the existing commands; +preserve unmatched records and the entire previous file on metadata, listing or write failures. + +Add browser device authorization for `auth login`, with bounded polling, cancellation and +owner-only credential persistence. Keep `--stdin` for an existing Auth Key, verified by a signed read. +Keep newly entered credentials independent from project dotenv endpoint settings; save an explicit +trusted endpoint with the key. Add `image init [--public | --private]`, with +opt-in private `.env.local` scaffolding via `--write-env`. Never overwrite existing application files. +Default store/sync catalogs to `transloadit.images.json`. Init writes an empty catalog and a runnable example +for `app` or `src/app`, preserving existing files. Store prints only the saved path and component +usage; its snippet-only public/private flags and init's dead next flag are removed. Keep upload +asset IDs and sizes on sync only when the HEAD MD5 still matches the stored receipt. + +Consolidate the unpublished Next factories into `createStorageImages`; select `public`, `authorize`, +or `delivery: 'direct'` explicitly. The authorize overload retains its typed redirect handler. +Require Next 16.3.3 or newer in the peer range. + +Reuse the login workspace and combined Auth Key for optional env scaffolding without extra prompts. +Add signed public-prefix declaration, revocation and listing methods with `storage publish`, +`storage unpublish` and `storage publications`. Public image init declares server policy before writing +files and explains that already cached public bytes cannot be recalled. + +Preserve the device key's signing algorithm in CLI credentials and subsequent API requests. +Add `signatureAlgorithm` to SDK client options while retaining the legacy SHA-384 default and +explicit per-call overrides. Init's env setup uses the saved key/workspace/endpoint together, +independently of stale project or shell credentials. Public/private Template overrides are separate. + +Public init stores workspace and published prefixes in the committed catalog, with no app env file. +Require public/private intent and bind Storage operations to the selected key's verified workspace. +Support multi-file store, auth status and server-side auth logout before removing credentials. +Infer allowed directories from +public policy even with an empty catalog, and accepts a missing trailing slash. Storage commands +report the winning credential source without showing credentials; store prints constrained JSX +bounded to the receipt width. Login makes a bounded read-only Storage policy preflight and gives +Console advice when unavailable. Keep the image quickstart concise and ship its detailed reference. diff --git a/.changeset/storage-image-seed.md b/.changeset/storage-image-seed.md new file mode 100644 index 00000000..3eee0a2c --- /dev/null +++ b/.changeset/storage-image-seed.md @@ -0,0 +1,13 @@ +--- +'@transloadit/node': patch +'@transloadit/types': patch +'@transloadit/zod': patch +'transloadit': patch +'@transloadit/mcp-server': patch +--- + +Include the Transloadit Storage import and store Robots in the offline catalog and generated +instructions, and type the optional `asset_id` in Assembly results. Recognize Storage import/store +error codes in response validation and terminal-status helpers, preserving API errors while polling. +Sync the canonical `recursive` option for Storage folder imports into the offline linter and +generated instructions without adding SDK-only schema fields. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45090311..668e68f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Ensure yarn.lock matches dependency changes + - name: Ensure each package's lockfile matches dependency changes env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} @@ -59,13 +59,11 @@ jobs: } return !file.startsWith('docs/fingerprint/') }) - const lockfileChanged = diffNames.includes('yarn.lock') - if (packageFiles.length === 0) { process.exit(0) } - const hasDependencyChanges = packageFiles.some((file) => { + const changedPackages = packageFiles.filter((file) => { let before = {} let after = {} try { @@ -85,8 +83,15 @@ jobs: }) }) - if (hasDependencyChanges && !lockfileChanged) { - console.error('yarn.lock must be updated when dependency ranges change in package.json.') + // The packed Next app is a standalone npm consumer, outside the Yarn workspaces. + const missingLockfiles = new Set(changedPackages + .map((file) => file === 'scripts/fixtures/img-next/package.json' + ? 'scripts/fixtures/img-next/package-lock.json' + : 'yarn.lock') + .filter((lockfile) => !diffNames.includes(lockfile))) + + if (missingLockfiles.size > 0) { + console.error(`${[...missingLockfiles].join(', ')} must be updated when dependency ranges change.`) process.exit(1) } NODE @@ -161,6 +166,12 @@ jobs: node-version: 24 - run: corepack yarn install --immutable - run: corepack yarn test:img:fixture + - uses: actions/upload-artifact@v7 + if: always() + with: + name: img-browser-evidence + path: test-results/img-next + if-no-files-found: ignore unit: name: Unit tests (Node ${{ matrix.node }}) diff --git a/.gitignore b/.gitignore index 8ec81799..5f52b340 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ sample.js npm-debug.log env.sh /coverage +/test-results/ packages/node/coverage .pnp.* diff --git a/biome.json b/biome.json index 21460c51..cbc89774 100644 --- a/biome.json +++ b/biome.json @@ -147,6 +147,14 @@ } }, "overrides": [ + { + "includes": ["scripts/fixtures/img-next/app/**"], + "linter": { + "rules": { + "correctness": { "useImportExtensions": "off" } + } + } + }, { "includes": ["**/package.json"], "formatter": { diff --git a/docs/img-dogfood.md b/docs/img-dogfood.md new file mode 100644 index 00000000..077dc7b9 --- /dev/null +++ b/docs/img-dogfood.md @@ -0,0 +1,462 @@ +# `@transloadit/img` local dogfood and verification + +These are maintainer-only packing, SDK seed and devdock notes, not consumer prerequisites. +Use the [package README](../packages/img/README.md) for the application integration. + +Responsive previews of Transloadit Storage objects, delivered through Smart CDN. + +Round 12's default integration is package-first: login, `storage store ./hero.jpg website/hero.jpg +--public`, `withTransloaditImages` in Next config, and `StorageImage` imported from +`@transloadit/img/next`. Commit both `transloadit.images.json` and `transloadit-images.d.ts`. +`image init` and the explicit SDK/factory recipes below are optional alternatives. + +The package renders native ``, `srcset`, and `` elements. Image bytes travel directly +from Smart CDN to the browser; they are never optimized or proxied by the Next.js application. +Remote HTTP URLs are deliberately outside this package's source contract: an image must already +belong to the configured Transloadit Storage workspace. + +This workspace remains private at version `0.0.0` while the API and production dogfood soak. Do not +depend on it from npm yet. + +## Seed your first image + +This walkthrough uses Node.js 24.11 or newer and an existing Next.js 16 App Router app. The +workspace must have Transloadit Storage writes enabled; package installation does not enable them. +Private delivery requires `builtin/storage-preview@0.0.2`. Public delivery additionally requires +`builtin/public-preview@0.0.1` and server-declared public prefixes. Browser login and combined +credentials require the matching API2 #9057 and Console changes; the older canary revisions +below predate these additions. Later owned devdock checks verified actual public delivery and +device login with scripted signed approval. They did not test the Console UI or production Bunny. +Private preview 0.0.1 stays unchanged and flattens transparency; +0.0.2 accepts `bg` for alpha-preserving candidates and the opaque JPEG fallback. + +The server entry point needs the **Node.js runtime**, not Edge: it uses `node:crypto` and `Buffer`. +The examples use root `app/` and `lib/` directories; adjust their relative imports for `src/app/`. +Keep the app's stock Next.js TypeScript configuration and Node/React type dependencies. + +### Install the local packages + +Use a clean checkout of [transloadit/node-sdk](https://github.com/transloadit/node-sdk), at the +reviewed revision of [PR #500](https://github.com/transloadit/node-sdk/pull/500). For example, +`gh pr checkout 500` selects that PR; record `git rev-parse HEAD` before packing. From that SDK +checkout, install its locked dependencies and pack into your own temporary directory: + +```bash +corepack yarn install --immutable +img_pack_dir=$(mktemp -d) +corepack yarn workspace @transloadit/img pack --out "$img_pack_dir/transloadit-img.tgz" +corepack yarn workspace @transloadit/node pack --out "$img_pack_dir/transloadit-node.tgz" +corepack yarn workspace @transloadit/types pack --out "$img_pack_dir/transloadit-types.tgz" +corepack yarn workspace @transloadit/utils pack --out "$img_pack_dir/transloadit-utils.tgz" +printf '%s\n' "$img_pack_dir" +``` + +In the same terminal, switch to a stock Next.js app created with npm and install all four local +tarballs. npm deduplicates the matching local workspace versions without a manual manifest edit: + +```bash +npm install "$img_pack_dir/transloadit-img.tgz" "$img_pack_dir/transloadit-utils.tgz" +npm install -D "$img_pack_dir/transloadit-node.tgz" "$img_pack_dir/transloadit-types.tgz" +``` + +The Assembly client is a seed-only development dependency. The optional instruction types are used +by the packed recipe's tests and by advanced `createAssembly()` calls, not the seed helper. Utils is a real +runtime dependency of img; the local tarballs are specific to this unpublished walkthrough. +Img does not add the Assembly client to the browser or create an Assembly for each render. Keep +the tarballs available for reinstalls; do not commit machine-specific paths as a production setup. + +For the ordinary first-image flow, return to the package README and use `npx --no transloadit` in place +of `yarn transloadit`. The explicit SDK seed below is an advanced maintainer alternative, not a +prerequisite for `auth login` or `storage store`. + +### Advanced alternative: configure a standalone seed script + +Skip this section when using `auth login` and `storage store`; those commands already seed the image. + +Use credentials from the **same workspace**. A Smart CDN-enabled Auth Key can serve both purposes +on the combined-key API2 revision; separate keys remain optional. This advanced script retains +explicit Assembly variable names to avoid accidentally loading a local endpoint into Next. Add both +`.env.seed.local` and `.env.local` to the app's `.gitignore` before creating them: + +The seed below selects `signatureAlgorithm: 'sha256'` for a combined key. A legacy Assembly-only +key may require `'sha384'` instead; match the algorithm configured on that key. CLI browser login +stores this metadata automatically for subsequent CLI requests. + +- `TRANSLOADIT_ASSEMBLY_KEY` and `TRANSLOADIT_ASSEMBLY_SECRET`: an **Assembly Auth Key** and its + secret, used to sign the one-time upload/store Assembly. Put these in **`.env.seed.local`**, + loaded only by the seed command below. +- `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET`: a **Smart CDN Auth Key** and its + secret, with Smart CDN enabled **and `smart_cdn:sign`** (`assemblies:write` is also accepted). + Put these in **`.env.local`** for Next.js. An Assembly-only key cannot replace this key. +- `TRANSLOADIT_WORKSPACE`: put the workspace's URL slug in `.env.local` too. In a Console URL such + as `/c/my-workspace/`, the slug is `my-workspace`, not a key or workspace ID. + +Do not use a `NEXT_PUBLIC_` prefix or commit credentials. Public-only rendering needs just the +workspace, not a key. Private rendering accepts `TRANSLOADIT_KEY/SECRET` or the explicit Smart CDN +override pair above. Next.js loads `.env.local`; all keys placed there must remain server-only. +`.env.seed.local` is outside Next's normal env-file names. Manage keys in the +[workspace Console](https://transloadit.com/c//template-credentials/). + +`TRANSLOADIT_ASSEMBLY_ENDPOINT` is an optional seed-only override. Omit it for the SDK default, +`https://api2.transloadit.com`; the local-devdock case is explained below. + +### Store one image and keep its verified metadata + +Save this as `seed.ts` in the app. `client.storeImage(filePath, { path })` uses your Assembly key +to store one local original at an explicit complete destination path. It streams the checksum, +waits for completion and verifies exactly one matching receipt: a nonempty typed `asset_id`, exact +path, byte count, MD5, and positive safe-integer image dimensions. It returns `StoredImageReceipt`. + +Underneath, one `/transloadit/store` Assembly annotates its input: the receipt is in +**`results[':original']`**, not `results.stored`. Those fields were verified in a real Storage canary. + +```ts +import type { StoredImageReceipt } from '@transloadit/node' + +import { Transloadit } from '@transloadit/node' + +/** Seed with an Assembly key, then save the receipt for rendering without another lookup. */ +export function seedStorageImage( + client: Transloadit, + filePath: string, + path: string, +): Promise { + return client.storeImage(filePath, { path }) +} + +async function main(): Promise { + const authKey = process.env.TRANSLOADIT_ASSEMBLY_KEY + const authSecret = process.env.TRANSLOADIT_ASSEMBLY_SECRET + const [filePath, path] = process.argv.slice(2) + if (!authKey || !authSecret || !filePath || !path) { + throw new Error( + 'Provide an Assembly key/secret and run: node seed.ts ./image.jpg website/image.jpg', + ) + } + const client = new Transloadit({ + authKey, + authSecret, + signatureAlgorithm: 'sha256', + endpoint: process.env.TRANSLOADIT_ASSEMBLY_ENDPOINT, + }) + console.log(JSON.stringify(await seedStorageImage(client, filePath, path), null, 2)) +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 + }) +} +``` + +Run it once for an image you want to store, keeping the printed record as app data: + + +```bash +( + set -e + receipt_tmp=$(mktemp ./image.json.XXXXXX) + trap 'rm -f "$receipt_tmp"' EXIT + node --env-file=.env.seed.local seed.ts ./canal-house.jpg website/canal-house.jpg > "$receipt_tmp" + mv "$receipt_tmp" image.json +) +``` + + +The temporary sibling is renamed only after success. A failed rerun preserves the previous receipt. + +The helper requires the full filename, not a directory or an interpolation expression. Advanced +`createAssembly()` instructions can use the single-quoted `'website/${file.url_name}'` literal: +Transloadit, not JavaScript, substitutes the input's URL-safe filename in that expression. Node 24 detects +ES module syntax when `package.json` has no `type`; explicit `"type": "commonjs"` is different. +The stock create-next-app manifest needs no change for this native TypeScript seed. No tsx or +ts-node runner is needed. See [Node's module detection](https://nodejs.org/download/release/v24.11.0/docs/api/packages.html#syntax-detection). + +Proceed only when the command exits successfully. `conflict_strategy: 'error'` makes a repeated +upload to the same path fail rather than silently replacing an asset. Choose a different filename +or explicitly set `overwrite: true` (CLI: `--overwrite`). Do not modify the input file while +it is being checksummed and uploaded. Receipt validation happens **after the Storage write**: +a validation error is not a rollback, and retrying the same path can encounter the stored object. +An `InconsistentResponseError` retains `cause.assemblyId` for investigation without copying the +Assembly response. Existing API, timeout and cancellation errors propagate unchanged. + +The helper also accepts `signal`, `chunkSize`, `onUploadProgress`, `onAssemblyProgress` and the +existing Assembly `timeout` (upload/polling, not local checksum time). It never accepts replacement +steps. Overwrite remains opt-in. Use `createAssembly()` for multi-file or transformation workflows. + +The resulting JSON contains `asset_id`, `path`, `size`, `md5hash`, `width`, and `height`. Keep it +alongside your content or in your application's database; rendering needs no metadata request. +The dimensions account for EXIF orientation, matching Storage preview's automatic rotation: +a stored 450×600 photo tagged “Rotate 90 CW” returns a 600×450 display size. +The `asset_id` identifies the stored asset. Pass the whole receipt as `src`; its path and dimensions +drive rendering. Public URLs carry `v`, a cache-busting tag derived from the receipt hash; the origin +does not verify it, so a cold request after an overwrite can return the replacement. Use immutable +filenames instead of overwriting published assets. Receipt IDs and other upload-only fields do not enter markup. The rendering +package does not import the Assembly client. + +### Direct devdock origin + +For a local devdock seed only, set `TRANSLOADIT_ASSEMBLY_ENDPOINT` to your trusted Assembly API +endpoint. This is separate from the Smart CDN origin. For direct devdock image delivery, configure +the image factory with the trusted URL Transform `baseUrl` (including its `{workspace}` placeholder) +and `urlParams: { cdn: 'required' }`. This supplies API2's explicit `cdn: required` acknowledgment +because native image requests cannot attach a custom header. It does **not** install a CDN or +bypass access policy. Private URLs still require a Smart CDN-enabled key; public URLs require a +published prefix. Never take either endpoint override from a request. +Normal Smart CDN delivery needs neither local override. +`createStorageImages` accepts these same `baseUrl` and `urlParams` fields directly, +alongside `allowedPathPrefixes` and `authorize`. + +The factory exports `StorageImage`. Use one flat `createStorageImages({ images, public })` +shape for the public Content hero, with catalog-typed src and fill/cover breakpoint ratios. +Public direct markup is static; private direct images remain request-rendered. See the package +README for layout and authorization policy; this document only covers maintainer setup. + +Before publication, a Yarn consumer may resolve a registry copy of `@transloadit/utils` under +the image package even when the new utilities tarball is a direct dependency. For local dogfood, +explicitly resolve that dependency to the same packed utilities. The release must bump utilities +and the dependent minimum versions together; publishing img against the old minimum is unsafe. + +### Live Storage listing and rendering receipt recovery + +The historical oriented-receipt canary used API2 `07ec5abc2b71d449a7474391c8eeef4934ef3589`. +It is stopped while waiting for the public/login revision. Only that internal-only, port-free devdock's +`env.sh` custom overrides enable `API2_STORAGE_S3_ENABLED=true`. Production remains unchanged. + +`transloadit storage ls website/ --json` discovers the workspace and lists the existing images, +including `website/stranger4.jpg` from the accepted stranger trial. Its 92,230-byte size and ETag +match the saved receipt. The command succeeds using the endpoint saved with ordinary read-scoped +Auth Key credentials, even with an unrelated bearer token present. An explicit `--endpoint` +overrides a saved local decoy; without that override the same decoy is reached. An unmatched prefix +returns an empty list, a different workspace returns 404, and unsigned HEAD returns 403. +These are real signed HTTP requests to API2, not mocked listing responses or new Storage writes. + +The first disabled-controller probe failed with HTTP 403. After enabling it, the canary's special +Assembly-admin shortcut was rejected with `InvalidAccessKeyId`; an existing normal read-scoped +workspace Auth Key works. No key was created or broadened, and Smart CDN credentials are not used +for listing. This local fixture distinction does not require an SDK authentication workaround. + +Public current and versioned HEAD/GET now return `dam-width=1024` and `dam-height=683` for the +stranger image. GET bytes match its saved MD5 and byte count. This closes the missing-public- +dimensions gap recorded against the earlier `5235a3605f` head. An asset ID is not needed for +rendering: `storage receipts sync --receipts images.json` uses signed List + HEAD to +recover path/width/height and an MD5 only when its ETag is compatible. No original downloads, +Assemblies, private backing-store credentials or fabricated upload-integrity fields are needed. +Keep committing the generated JSON before building; it is now recoverable from catalog metadata. + +The rotated-photo discrepancy is fixed and independently verified on `07ec5abc2b`. Two fresh +uploads preserve their original bytes while reporting display-oriented dimensions everywhere: +`rotated_8.jpg` is encoded 450×600 and displays at 600×450; `receipt-exif-orientation.jpg` is encoded +616×800 and displays at 800×616. Both carry EXIF orientation 6, independently read with ExifTool. +SDK receipts, current asset/version rows, backing object metadata, current/versioned public +HEAD/GET and the actual packed receipts-sync CLI all agree. The recovered JSON has both expected +display sizes and original MD5s, without fabricated asset IDs. + +Older `website/construction.jpg` still has no dimensions, so a sync containing it must fail without +replacing the existing file. New uploads used fresh paths; this proof does not backfill historical +objects. No API2 implementation or canonical schema was changed by this SDK follow-up. + +Current oriented proof: `/tmp/img-exif-live.log`, clone17's `tmp/img-exif-sync-result.json` +(2026-09-13T01:20:12.868Z). The earlier failed `b4aba072ee` upload/HEAD assertion remains in +`tmp/img-sync-oriented-receipt.json`; listing/endpoint proofs remain in `/tmp/img-sync2-head.log` +and `/tmp/img-storage-ls-result.md` as historical evidence. + +The actual packed `storage receipts sync website/stranger --receipts images.json --json` failed +first on the old package, then passed after normal installation of the new SDK tarball. It +recovered all three stranger images at 1024×683 with the original MD5, without asset IDs. The +explicit endpoint bypassed a saved decoy; without override the decoy was reached. Empty results +preserved existing entries, and including the legacy object with missing dimensions failed +without changing the complete prior file. Evidence: `/tmp/img-sync2-live-{red,green}.log` and +clone17's `tmp/img-sync2-cli-result.json` (2026-09-12T22:22:14.120Z). + +## Verification + +```console +corepack yarn workspace @transloadit/img check +corepack yarn test:img:fixture +``` + +The fixture packs all four local artifacts and installs them with its pinned **npm** lockfile into +a clean Next.js app. It executes this exact seed recipe against mocked Assembly receipts without +network access and compiles it against the packed SDK/types. It builds and serves both production +Cache Components configurations, then runs 72 Chromium/WebKit cases (18 cases × 2 engines × +2 configurations): native cookie authorization, +GET/HEAD parity, explicit public-prefix caching, responsive art direction with real cropped bytes, +separate app/CDN hosts, constrained hero/fixed avatar geometry, portrait fillcrop, optional error +fallback and same-page sign-in/refresh recovery, the actual CLI-generated empty and populated pages, +unsigned public Built-in policy and immutable versioned responses, the constrained public page +without a CSS reset, private-redirect decoding before application JavaScript, hydration, bounded JPEG fallback, +original-capability renewal, revocation, expiry and tampering. Chromium +also verifies direct streaming before application JavaScript; direct WebKit navigation uses normal +script loading because holding bundles can stall React's streaming reveal in the test browser. +That extra WebKit pre-JS scenario remains unverified. The owned +local image origin independently enforces signatures/expiry and declared public prefixes, then serves real encoded bytes; it +never receives the application's session cookie. Transparent AVIF/WebP/PNG corners and the signed +opaque JPEG background are checked at the pixel level. This origin emulates the Built-in contract; +it does not execute API2's transformation pipeline. Secret scans cover rendered/client artifacts. +The generated public-only application also builds in both modes with no signing credentials. + +The test records browser evidence and direct-versus-redirect HTML size and route work for 1, 20, +and 100 images. Wall-clock measurements are diagnostic, not CI performance thresholds. This local +proof does not measure production CDN latency/caching or prove the deployed API2 pipeline. +The fixture checks the packed dependency graph independently of the own-devdock consumer test. + +## Archived PR verification through round 10 + +The following commit-stamped ledger previously lived in #500's description. It records historical +checks, not a claim that they ran on the latest head. Current round-11 receipts are recorded separately. + +## Round 8 verification + +Source verification on `00f5ec0` (all review findings reconciled): + +- `@transloadit/img check`: 258 tests plus type checks pass. +- Full repository verification including knip/types: 537 Node tests pass (one preexisting skip), + plus the image, utils, schema and relay checks. +- Packed native Chromium/WebKit fixture: 80 first-attempt passes with Cache Components enabled + and omitted. Independently audited local and downloaded CI evidence has no skips, retries, + flakes or unexpected errors. +- [Exact-source CI](https://github.com/transloadit/node-sdk/actions/runs/34792479448) is green: + Verify fast/full, Node 20/22/24, E2E, release dry run and the browser fixture. +- Final documentation-only head `5561c31` also has + [green CI](https://github.com/transloadit/node-sdk/actions/runs/34793045157); its downloaded + browser artifact independently passes the same 80-case audit, without retries or skips. +- Live API2 `b2264e1767`: ordinary packed installation, fresh zero-env public scaffold, actual + device login/key metadata, five wrong-workspace refusals, upload, List + HEAD recovery, decoded + direct public delivery at desktop/mobile, unpublish and self-revoking logout all pass at 3e473f4, + including API2's unrestricted (`signature_algo: null`) key. Final fixes do not change the direct + image byte path; the real API2 receipt remains explicitly stamped `3e473f4`, not relabeled. + The unchanged consent/logout path + separately passes all 14 live checks, including imported-key consent and negated-flag refusal. +- Actual OS SIGINT and SIGTERM sent to the current built CLI during stalled discovery, listing, + HEAD, publish, unpublish and public init: all twelve cases preserve the catalog and remove its + lock with a graceful exit. The packed fixture also builds generated mixed-catalog + public/private scaffolds rather than only hand-maintained examples. +- Claude UX and defensive security reviews pass; their useful findings were fixed red-first. + Council findings are fixed with regression coverage; targeted Opus closure reports PASS with + no blockers. One unrelated preexisting lint warning remains; verification has no errors. + +The live canary uses only the owned local API2/devdock and toy object storage. Scripted signed +device approval is not proof of the production Console UI or a human onboarding time. +Safe CLI logout uses API2's `DELETE /auth_keys/self`; no broad Auth Key management scope is added. +Both CLI manifests require Node 20.10.0+ for their actual JSON import/cancellation primitives. +Temporary local keys/publications were cleaned up and task-owned servers stopped. No claim of +production Console or Windows runtime coverage. Local report: `/tmp/img-task2-round8-report.md`. + +## Round 8 signup-test addendum + +On `ce623e2`, a completed Storage write uses the Assembly's authoritative receipt even when +the server changes the upload bytes. The CLI saves that metadata, explains changed bytes or +checksums, and does not suggest overwriting. External Assembly-ID recovery remains strict. +Missing receipts get targeted, shell-quoted list/sync commands preserving the selected endpoint, +workspace and catalog. Canceled/in-progress Assemblies retain their status-specific message. + +README/reference commands are npm-first, signup is explained, and store/sync help agree on +`transloadit.images.json`. Waiting browser approval emits a safe stderr heartbeat every minute. +Debug output is restricted to verified receipt facts, Assembly ID and comparisons; raw Assembly +responses, signed URLs and credentials are never dumped. Async observer failures are contained. + +The six council findings and three minor Opus suggestions were fixed red-first. Full local +`yarn check` passes: 549 Node tests (one preexisting skip), 258 img plus utils/schema/relay checks. +The packed Chromium/WebKit matrix passed 80 first attempts after the six review fixes. All +jobs in [code-head CI](https://github.com/transloadit/node-sdk/actions/runs/34795854316) are green; +its downloaded browser artifact independently confirms 80 first attempts with zero retries, +skips, flakes or unexpected errors, including the final warning/help/reference polish. +Opus returned PASS/no blockers for the changed UX and defensive-security boundaries. + +Real Community-plan canary with an ordinary npm-installed `ce623e2` tarball: store, default +catalog, listing, receipt sync and byte-identical signed CDN delivery pass. API2 `b2264e1767` +already exempts stored originals from watermarking; older transformed-response compatibility is +tested with protocol fakes, not claimed as live old-policy coverage. The temporary local DNS +failure was traced to the owned devdock's missing tmp hostname and repaired without API2 source, +env or production changes. Console signup/redirect fixes remain with their owner. +Detailed evidence and limitations: `/tmp/img-task2-round8-addendum-report.md`. +Final documentation-only head `c7017d3` also has +[green CI](https://github.com/transloadit/node-sdk/actions/runs/34796223956); its downloaded +browser artifact separately confirms the same 80 first-attempt passes without retries or skips. + +## Round 9 — stranger-test follow-ups + +Completed on `3b26679d4d618334e2cb58270caeac595abe6306`: + +- F11: development size hints wait for decoded, laid-out images, ignore transient 0/1px boxes + and account for native density correction. Correct cached desktop candidates reused on mobile + no longer blame `sizes`; genuinely oversized candidates still warn. +- F9/F10: the generated scaffold includes an accessible visible delivery-error fallback and + explains decorative alt text. Diagnostics no longer prescribe an unnecessary server restart. +- F2/F3: existing-login refusals name the safe saved identity, file and modification date and + offer a separate credentials file. Concurrent logins preserve the winner and distinguish newly + approved keys from imported application keys. Auth help lists each command once, retaining aliases. +- README: short npm/pnpm/Yarn entry, maintainer-supplied unpublished tarballs, signup/code timing, + Next.js floor, named delivery overrides and the actual Console **Credentials** sidebar label. + +Verification: + +- Full `yarn check`, then sequential img check → verify → packed consumer fixture pass: + 263 image tests, 560 Node tests plus one preexisting skip, and utils/schema/relay/root/MCP checks. +- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34800196222) is green on + its first run attempt: Verify fast/full, Node 20/22/24, E2E, release dry run and browser fixture. +- Independently audited local and downloaded CI artifacts confirm **92 native Chromium/WebKit + first-attempt passes**: 42 with Cache Components enabled, 42 omitted, eight in development. + Zero retries, skips, flakes or unexpected browser/network errors. No audit exemption was added. +- Council closure reports no issues. Claude UX and defensive-security reviews both PASS; + valid findings were fixed red-first, including the concurrent-login cleanup wording. +- Ordinary npm-installed manual desktop/mobile proof covers decoded images, cached candidate + reuse, accessible failure and Fast Refresh recovery with the same server PID. It uses owned + localhost contract fakes, not a new API2/Console signup canary or production Bunny measurement. + +The dev tiny-box test initially rewrote streamed HTML, inducing a WebKit reload/font cancellation. +It now changes only CSS, asserts native resize observation and preserved hydrated state; initial +pre-layout scheduling remains unit-tested. Failed attempts and the final green receipts are +retained in `/tmp/img-task2-round9-report.md`, alongside `/tmp/img-r9-evidence-OWBFjJ/`. +One preexisting lint warning and Node unit skip remain. Own services are stopped and the worktree +is clean. The incoming **6m13** signup result remains the earlier reader's measurement. + +No merge/publication, dependency/schema/Built-in/auth-scope changes, API2/Content edits, env-file +edits or production changes in round 9. `@transloadit/img` is still private at 0.0.0. + +### Round 9 follow-up — response-read ownership + +Completed on `8c39cded80ef638cde7f05db0b509fd2abab8540`. The reported +`response.body: Test ended.` was a spec lifecycle race: the reads-array snapshot could miss +later work. Response listeners now return their async work to Playwright; main and probe +cleanup remove/drain those listeners before page/context closure. No product code, retry, +timeout or browser-error exemption changed. + +- Red-first native regression holds the audit body read while the browser decodes normally. + Both engines fail on the old handler; both wait correctly after the fix. +- Focused council: no issues found. Full `yarn check`, then sequential img check → verify → + packed fixture pass on this head (263 img, 560 Node plus the existing unit skip). +- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34801987887) is green on + attempt 1. Independently audited local and downloaded CI artifacts confirm **96 native + first-attempt passes**: 44 enabled, 44 omitted and eight development; no retries, skips, + flakes or unexpected errors. +- Evidence: `/tmp/img-task2-round9-report.md`. The earlier 92-case receipts above remain + attributed to their original head. No merge or publication. + +## Round 10 — final reader residuals + +Completed on `2befc537b3736617ed28080b80891c40731c8a49`. Development diagnostics now name +the actual probed origin/path, without query values or credentials. Generic HTTP failures and +unreachable hosts get distinct wording and the `baseUrl/urlParams` factory hint; publication, +auth and 404 advice, background timing and production silence remain unchanged. + +The six requested README clarifications cover the workspace-derived CDN host, saved login API +endpoint, separate credential file, Console Credentials path, optional `src/` layout and supplying +a JPEG. Council's sole P3 clarified that the credential-file override belongs in the shell; +fixed red-first. README stays at 180 lines, Quickstart at 36. No other product work. + +- Seven diagnostic regressions fail before the fix; the affected suites pass afterward. + Real loopback HTTP 400 and closed-port probes verify the URL, distinct advice and redaction. +- Full `yarn check`, then sequential img check → verify → packed consumer fixture pass: + 264 img tests, 560 Node tests plus the existing skip, and root/utils/schema/relay/MCP checks. +- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34803447265) is green on + attempt 1. Independent local and downloaded artifact audits confirm **96 native first-attempt + passes** (44 enabled, 44 omitted, 8 development), with no retries, skips, flakes or unexpected + errors. Browser assertions and network exemptions were not changed. +- Report: `/tmp/img-task2-round10-report.md`. The incoming **4m58** signup result remains the + independent reader's measurement on 3b26679/API2 b2264e1767/Content 40210c67f1, not this head. + +Stop after this round. No new reader round, merge, publication, production requests, +API2/Content edits or environment changes; `@transloadit/img` remains private at 0.0.0. diff --git a/docs/prompts/2026-09-12-img-review.md b/docs/prompts/2026-09-12-img-review.md new file mode 100644 index 00000000..14de7758 --- /dev/null +++ b/docs/prompts/2026-09-12-img-review.md @@ -0,0 +1,34 @@ +# Storage image onboarding and delivery + +## Why + +Finish the private, unpublished `@transloadit/img` integration in node-sdk #500. One original, +one typed catalog and one factory should serve public marketing images and authorized private +images without proxying image bytes through Next. + +## Contract + +- One `createStorageImages` factory; explicitly choose public prefixes, an authorizer or direct delivery. +- Declared-public prefixes use permanent unsigned Smart CDN URLs; private delivery keeps bounded + signatures, native-cookie authorization and opaque redirect capabilities. +- Browser CLI login saves a server-only Auth Key and workspace; init creates a runnable example + and catalog. Store validates upload evidence; sync preserves it only when the MD5 agrees. +- Device creation and token polling send form-urlencoded fields; approval uses signed API params. + Public directory declarations are bounded to 512 UTF-8 bytes, including their trailing slash. +- Public delivery requires the matching API2 public-prefix contract and public Built-ins. + API2 and Console implementation are separate work; test SDK boundaries with contract fakes + until the owned devdock is updated. +- Preserve strict path scope, transparent previews, EXIF dimensions, catalog types and native + responsive layout. No arbitrary-origin loader, application byte proxy or silent overwrites. + +## Verification and release gates + +Write failing regressions first. Run package checks, repository verification and the packed Next +fixture in both Cache Components modes with Chromium/WebKit. Reconcile independent council and +security findings; verify exact-head GitHub CI. Then test actual device approval, public/private +delivery and the Content hero against the owned devdock. + +Keep detailed session evidence outside the repository. Maintain reproducible consumer instructions +in the package README and `docs/img-dogfood.md`. Do not merge or publish without Kevin's approval. +Before release, coordinate API2/Console deployment and matching utils/types/node/img versions, +ordinary registry installs, sustained Content dogfood and timed tests with real developers. diff --git a/docs/prompts/2026-09-13-img-onboarding.md b/docs/prompts/2026-09-13-img-onboarding.md new file mode 100644 index 00000000..edd891fd --- /dev/null +++ b/docs/prompts/2026-09-13-img-onboarding.md @@ -0,0 +1,531 @@ +# Round 7 onboarding review + +Why: the public-image quickstart must remain secretless from login through the first image, +and errors must describe the API's actual wire contract. + +PR: https://github.com/transloadit/node-sdk/pull/500 + +- [x] Diagnose unpublished paths via `Transloadit-Error: NO_SIGNATURE_FIELD`; retain generic + HTTP 400 advice and path/template advice for 404. +- [x] Infer allowed public prefixes with an empty catalog; preserve explicit deny-all policies. +- [x] Public init writes only the workspace; private init retains rendering credentials. +- [x] Print bounded, constrained JSX from store and disclose the selected credential source. +- [x] Rename the policy listing command, normalize init prefixes and preflight Storage after login. +- [x] Keep the quickstart focused and reference details separate; qualify test and byte counts. +- [x] Run sequential package/repository/browser checks and council/Opus reviews. +- [x] Test the real login key with List + HEAD sync and a fresh public-only Next app in devdock. + +Council's source-width clamp is covered red-first and in the packed browser matrix: a 320px +original cannot stretch to a larger constrained maxWidth. Final exact-head CI remains a merge +gate; package checks count executed parameterized cases, not test declarations. + +No merge or publication in this round. No blur placeholder, origin version selector, workspace +picker or logout implementation. API2 owns the S3 eligibility and error-header changes; Console +owns real browser approval. Local canaries do not establish production Bunny latency. + +## Round 8 + +Why: a public project needs one committed source of truth and familiar image props; the CLI +must not silently write into a different workspace than the application renders. + +- [x] Commit workspace, public prefixes and receipts in `transloadit.images.json`; keep explicit + `--receipts` paths, and require `--public` or `--private` at init. +- [x] Verify project/workspace binding before store, list, sync, publish and unpublish. +- [x] Default catalog/receipt images to constrained `width`; make `priority` eager/preload/high. +- [x] Derive art-direction container ratios from the same map as crop candidates. +- [x] Unify duration units, isolate experimental props, document explicit basePath and diagnose + oversized candidates only in development. +- [x] Add scoped logout/status, multiple-file storage and copy-safe alt/Windows instructions. +- [x] Document deployment and release dependencies; remove unmeasured performance claims. +- [x] Run red-first tests, sequential package/full/browser checks, council, local UX/security, + the live zero-env/mismatch/logout canary and exact-head green CI. + +API2 b2264e1767 supplies `DELETE /auth_keys/self` and token `auth_key_id` / `description`. Real +packed login/status/logout passes in owned devdock17 without broad Auth Key management rights. +The zero-env public scaffold renders real unsigned images in Chromium/WebKit at desktop/mobile; +all five bound Storage commands refuse the wrong workspace before acting. + +Council's large-original, height-only and bearer-token/fallback-credential findings were reproduced +red-first and fixed. UX review found no blockers. The mismatch wording is Kevin's explicit +contract; JSON-quoted generated imports deliberately escape arbitrary receipt-file paths safely. +CLI next-step wording is usable but could be more copy-paste-oriented in a later polish pass. +Security review's empty-discovery and endpoint-provenance edges are covered red-first; a changed +shell endpoint requires fresh workspace discovery. API2 keys are workspace-scoped, so ambiguous +multi-bucket responses fail closed with endpoint/key advice, not an ineffective override suggestion. +Private `--write-env` only persists the saved login, never transient shell fallback credentials. +The follow-up council caught imported application-key revocation: login now records its method; +logout forgets imported/legacy keys unless `--revoke` is explicit, and still revokes browser-login +keys. Docs warn that applications sharing a browser-login key also lose access on logout. Red-first +tests cover provenance spoofing in stdin, imported opt-in, legacy files, failed cleanup preserving +publication warnings, and actionable bearer-token advice. Actual runtime smoke tests showed JSON +import attributes need Node 20.10.0, beyond AbortSignal.any's 20.3.0 floor; both CLI manifests agree. +The next review's project-selected credential-file bypass is closed for both ownership flags; +only the shell-selected login path can retain verified provenance. Local-only logout can forget +unusable legacy credentials, but explicit revocation still requires a valid signing key. +`--no-revoke` is rejected rather than silently ignored. Recovery links use the command's actual +workspace. Red-first cases cover these boundaries; workspace-option precedence is documented. +Live logout also exposed the documented asynchronous API2 cache boundary: explicit revocation +soft-deletes immediately, while this daemon-free test uploader denied reads after 116 seconds. +The failed immediate-denial assumption is preserved; this is not a production revocation SLA. +Additional reviewed onboarding edges are covered red-first: nullable API2 signature algorithms, +private initialization beside existing public directories, and examples choosing only a receipt +under the requested directory. The packed fixture now builds these actual mixed-catalog outputs. +Login also snapshots its default home before dotenv injection, with an OS-user fallback for +Node's empty-HOME result, so a project cannot redirect new credentials through HOME/USERPROFILE. +The final lifecycle review restores explicit expiry at the signed-candidate boundary, detaches +long-lived browser openers and adds cooperative interrupt cleanup to the existing atomic catalog +writer. Active uploads/discovery/list/HEAD cancel; completed receipts checkpoint before exit. +An accepted Assembly may still finish remotely, and forced exits/crashes still need lock inspection. +Relative HOME values also fall back to the OS account directory. Empty examples and init output +now name the initialized directory. These cases are red-first and covered by real loopback S3 +requests plus the packed fixture; the latest full `yarn check` passes. +Final review reconciliation forwards cancellation into publication HTTP calls too (publish, +unpublish and public init), distinguishes successful checkpoints from failed receipt recovery, +limits unversioned compatibility redirects to one shared-cache minute and clarifies JSON ETags. +Opener failures are warnings, not false login results on JSON stdout. All have red-first coverage. + +Round 8 source verification is complete on `00f5ec097cb4809878343a31b6f85cb982ee796e`: +full `yarn check`, then img check → verify → packed fixture pass (258 img, 537 Node plus one +preexisting skip, 59 utils, 22 relay). Local and downloaded CI artifacts independently confirm +80 native browser passes with no retries, skips, flakes or unexpected errors. All jobs in +https://github.com/transloadit/node-sdk/actions/runs/34792479448 are green. Actual SIGINT/SIGTERM +tests pass across six stalled CLI phases, including publication, and targeted Opus review confirms +all final council fixes with no blockers. One preexisting Biome warning remains outside this scope. +The last full real API2/browser canary is explicitly `3e473f4` (20 observations, unrestricted key); +the final corrections above do not change its direct image byte path. The final report retains +failed runs, later passes and source hashes separately, including API2's asynchronous revocation +boundary. Task-owned services are stopped and API2's preexisting working changes are preserved. + +No merge, package publication, Content changes, Thumbhash or origin version selector in this round. +Detailed local receipts, review reconciliation and remaining gates: `/tmp/img-task2-round8-report.md`. + +## Round 8 signup-test addendum + +Why: older deployments watermark Community uploads before Storage runs, so a successful write can differ +from the local file. The CLI must save authoritative receipt metadata and explain this, not invite +a destructive retry. Input: `/tmp/img-task2-round8-addendum.md` and the stranger signup report. + +- [x] Accept valid single-original Storage results from the requested write despite changed bytes; + preserve strict path/asset/dimension validation and separate trusted-fact Assembly recovery. +- [x] Save the actual receipt, warn about changed bytes/plan transformations, and show bounded + debug comparison details. Missing receipts get list/sync recovery advice, never overwrite advice. +- [x] Make the README npm-first, explain signup/free-plan watermarks and align CLI help examples. +- [x] Report pending browser approval about once per minute without leaking credentials. +- [x] Explicit init delivery choice and self-key logout already implemented and covered in round 8. +- [x] Red-first tests, focused review, package/full/packed checks and exact-code-head green CI. + +Console signup/redirect/sidebar fixes remain with its owner. No merge, publication or watermark +policy change is authorized by this addendum. +API2 `cb23326114` already exempts Storage originals from Community watermarking; qualify that +warning as compatibility with older deployments. Evidence: `/tmp/img-task2-round8-addendum-report.md`. + +Completed code head `ce623e261393f41c5c7859ceb601c54daecee6f2`: full `yarn check`, then img check +and `verify:full` PASS (549 Node + one preexisting skip, 258 img, 59 utils, 22 relay and the +root/schema/MCP/type checks). All jobs in +https://github.com/transloadit/node-sdk/actions/runs/34795854316 are green. Its downloaded browser +artifact independently confirms 80 first-attempt Chromium/WebKit passes in both Cache Components +modes, zero retries/skips/flakes/unexpected errors. Evidence: `/tmp/img-r8a-final-ci-browser-audit.json`. + +The six council findings and three minor Opus suggestions are fixed red-first; both focused Opus +UX/defensive-security reviews PASS. Recovery commands keep endpoint/workspace/catalog and use a +filename prefix, canceled/pending Assemblies retain status-specific messages, asynchronous receipt +observer errors are contained, and approval countdowns use a monotonic clock. Reference and sync +help now agree with the npm-first Quickstart; checksum-only transformations are explained too. + +An ordinary npm-installed tarball of that exact head passes the real local Community canary on +API2 `b2264e1767`: store, default catalog, listing, receipt sync and byte-identical signed CDN +delivery. Its original-storage watermark exemption is confirmed. Compatibility with older +transformed results remains explicitly protocol-fake coverage, not live old-policy proof. +The failed extra readback was a missing tmp-hostname mapping after restarting the owned devdock; +only that container mapping was repaired. No API2 source/env, production policy or Console edits. +Temporary canary keys/credential files were removed, own devdock/S3rver stopped, existing API2 +working changes preserved. The PR retains `Refs #270`, not `Fixes`, and its two open follow-ups. + +Next gates remain unchanged: Console signup/redirect ownership, coordinated deployment/release, +ordinary registry-install and sustained Content dogfood, then production Bunny measurements. +This addendum does not authorize merging or publishing the private image package. + +## Round 9 — stranger signup test 2 + +Input: /tmp/img-task2-round9-brief.md and /tmp/img-stranger-signup-report-r2.md (PASS in 6m13, +against earlier tarballs). “This fires on **every** load of the untouched `image init` page” +makes F11 the first fix. “a blank page with nothing on it at all” makes scaffold failure UI next. + +- [x] F11: post-layout, decoded, non-tiny candidate measurements; native dev tests added. +- [x] F9: generated example displays delivery failures using the existing fallback API. +- [x] F10: remove unnecessary development-server restart advice without promising retries. +- [x] F2: identify saved credentials safely and show the non-destructive separate-file path. +- [x] F3: unique auth help from Clipanion definitions; all existing aliases retained. +- [x] README: short npm/pnpm entry, signup timing, tarballs, delivery override and Credentials label. +- [x] Red-first checks, council/Claude review and sequential package/packed checks. + +Same PR; no merge, publication, production, Console or API2 changes. Keep Refs #270 and the +unchanged release gates above. Detailed quotes, decisions and evidence: /tmp/img-task2-round9-report.md. + +Initial yarn check passes: img 262, node 558 plus one existing skip. Red-first failures cover +pre-layout diagnostics, missing scaffold fallback, restart advice, login context, duplicated +help and README entry points. The native dev matrix adds untouched desktop/mobile scaffolds +and a controlled 1px-to-960px real layout; final browser/review/CI receipts follow below. +F3 was alias expansion in prefix help, not duplicate command registration. No dependencies, +schema files, builtin pins or auth scopes changed. + +First council found two valid follow-ups: scope the fallback locator past Next's route +announcer, and explain an approved-but-unsaved key after concurrent login rather than saying +“Nothing was changed”. Both are fixed red-first. Interactive desktop-to-mobile browser reuse +also exposed a density-correction false positive; compare CSS-pixel naturalWidth before blaming +sizes. Native tests cover both unchanged pages and cached-candidate reuse. The final verification +sequence and second, focused review are pending; initial CI only failed on the alert locator. + +### Round 9 verification and handoff + +The final local sequence passes: `yarn check`, then img check → verify → packed fixture. +Counts: 263 img, 560 Node plus one preexisting skip, 59 utils, 22 relay and the root/schema/MCP +checks. The downloaded/installable packages pass all 92 native Chromium/WebKit cases: 42 with +Cache Components enabled, 42 omitted and eight on the actual Next development server. An +independent audit confirms zero retries, skips, flakes and unexpected browser/network errors. + +Two-reviewer council closure reports no issues; independent Opus UX and defensive-security reviews +both PASS. Manual npm-installed desktop/mobile evidence covers working images, cached native +candidate reuse, visible accessible failure and Fast Refresh recovery without restarting Next. +Real installed CLI help lists six unique auth commands and refuses to overwrite toy credentials +while identifying the saved file/workspace/description/date without printing secrets. A losing +device login explains its approved-but-unsaved key; a losing stdin login never suggests revoking +the existing application key it merely verified. Both races have red-first no-overwrite coverage. + +The first closure CI run passed 91/92 cases: rewriting streamed dev HTML for the tiny-box test +caused WebKit to reload and cancel a devtools font. The test now changes only CSS after normal +hydration, verifies 1px → 960px and retained client state. Initial pre-layout timing remains +unit-tested; untouched native desktop/mobile loads and resize observation remain browser-tested. +No failed-request exemptions or retries were added. Its test-only Node handle typing error was +also fixed before the final full local repeat. The report preserves those failed attempts. + +Exact-head green CI is the final handoff gate; its current receipt and audited downloaded artifact +are recorded in the [PR body](https://github.com/transloadit/node-sdk/pull/500) and +`/tmp/img-task2-round9-report.md`, so this source document does not require a self-referential SHA. +Local evidence: `/tmp/img-r9-evidence-OWBFjJ/`; final sequential logs: `/tmp/img-r9-final2-*.log`. +Own manual browsers and servers are stopped; the packed runner cleans its own servers. + +The incoming 6m13 stranger-signup duration is not relabeled as a new timing on this head. Round 9 +uses owned localhost contract fakes, not a new API2/Console or Bunny canary. No dependencies, +schemas, Built-in pins, auth scopes, Content/API2 source, env files or production settings changed. +The release gates remain: coordinated API2/Console rollout and package release, ordinary registry +installation, sustained Content dogfood and production Bunny measurements. No merge or publication +in this round; `@transloadit/img` remains private at 0.0.0 and #270's two follow-ups stay open. + +### Round 9 follow-up — response-read ownership + +Kevin reported `response.body: Test ended.` in run 34788426459 at cd9430a0eb. Its rerun passed, +but the same listener lifecycle remained at 3b26679: Promise.all snapshots a growing reads array, +leaving later response handlers unowned during teardown. No product behavior is implicated. + +- [x] Reproduce deterministically in both real browser engines by holding the audit's body read + while the image decodes normally; old cleanup incorrectly completes before that read is released. +- [x] Return the response handler's async work to Playwright, then remove/drain those listeners + before page/probe closure. Remove the manual reads array; preserve native read failures. +- [x] Focused council: no issues found. Packed browser matrix: 44 enabled + 44 omitted + 8 + development cases pass on their first attempts, with all response-audit attachments checked. + +The final gate is the required local checks and exact-head CI/artifact verification; their +commit-stamped receipts live in the report and PR body below, not in a second docs-only CI loop. + +Keep this correction test-only. Evidence and current-head CI receipts are appended to +`/tmp/img-task2-round9-report.md` and the PR body; the red browser artifact is +`/tmp/img-r9-response-race-red-artifact`. No ignoreErrors, extra retry, timeout increase, +product change, merge, publication or new framework is part of this fix. + +## Round 10 — final reader residuals + +Input: `/tmp/img-task2-round10-brief.md` and `/tmp/img-stranger-signup-report-r3.md`. +The independent reader passed in 4m58 on 3b26679 (API2 b2264e1767, Content 40210c67f1). +That is the reader's existing timing, not a new measurement by this round. + +- [x] F9: print the actual development HEAD origin/path, stripping query credentials. Explain + HTTP failure versus an unreachable host and point non-default setups at baseUrl/urlParams. + Preserve the existing publication/auth/404 hints, background timing and production silence. +- [x] Six README clarifications: workspace-derived delivery host, persisted login endpoint, + separate credentials file by the logout warning, Console Credentials path without a placeholder + link, optional src/ layout, and bring-your-own hero JPEG. No new onboarding feature. +- [x] Red-first diagnostic tests: seven failures before the fix, then all 163 affected tests pass. + Focused council found only the shell-versus-project-env wording; fixed red-first in the README. + +Required local checks, packed browser proof, final exact-head CI/artifact receipts and PR-body verification go in +`/tmp/img-task2-round10-report.md`. Stop after this round: no merge, publication, API2/Content +changes, environment edits, new reader round or extra product work. img stays private at 0.0.0. + +## Round 11 — recovery and reader follow-ups + +Input: `/tmp/img-task2-round11-brief.md`, with both independent round-8 Rauch reports read. +Work remains in #500 on `img-onboard`; no merge, publication or production changes. + +- [x] Recover server-declared public policy atomically with receipts; preserve the catalog on + policy failure. Never infer publication from a folder name. +- [x] Document a separate application key for private deployment, not the revocable CLI key. +- [x] S3 availability/403 handling; non-production init delivery override; dev HEAD fallback. +- [x] preload vocabulary, typed scaffold and store snippets, concise output, publication dry run. +- [x] Quickstart-first README and reference accuracy; browser device-denial contract. +- [x] Triage both focused councils and the Opus UX/security review; add red-first regressions. +- [x] Condense the PR body, preserving historical evidence in maintainer documentation. + +Pre-review full `yarn check` passes: 267 img and 571 Node tests (one existing skip). +Native desktop/mobile proof and independent Opus UX/security reviews pass on the initial head. +Council's shell-quoting and custom-catalog findings are fixed red-first. The Opus follow-up fixes +scaffold formatting, stale alt docs and missing empty-policy guidance. The workspace env override +remains the explicit round-8 contract, now documented. Final packed verification also replaces its +stale empty-scaffold assertion; that initial local/CI failure is retained in the report. +The closure council's multiline assertion and portable absolute-catalog follow-up are fixed; +full `yarn check` now passes with 268 img and 572 Node tests (one existing skip). +Cold-checkout CI then exposed the generator's unnecessary receipt-module import. The CLI now +owns the default catalog argument, keeping source generation independent of built packages; +a child-process cold-load regression fails first and passes after the correction. +The final handoff gate is img check → verify → packed browser proof → exact-head green CI. +Its current commit-stamped result is recorded in the report and PR body rather than a +self-referential docs-only commit. No merge or publication is authorized by passing this gate. + +Progress and red/green evidence: `/tmp/img-task2-round11-report.md`. Immutable identity and +image doctor remain out of scope. `@transloadit/img` stays private at 0.0.0. + +## Round 12 — package-first Next.js integration + +Kevin's decision: `/tmp/img-task2-round12-brief.md`. Keep #500, private 0.0.0, no +merge, publication or production changes. Current main is already an ancestor; no open GitHub +review threads were present at preflight. + +- [x] Store owns the catalog, generates committed types, and explicitly publishes with --public. +- [x] A Next config plugin binds the conventional catalog and private authorizer; direct package + import reuses the existing renderer. Do not promise a runtime filesystem fallback without + proving tracing and host portability. +- [x] Generated module augmentation narrows src; without it string sources still use catalog sizes. +- [x] Private convention exports one authorize function and re-exports the package route handler. +- [x] Keep explicit factories and opt-in example/private scaffolding working. +- [x] Quickstart/reference/dogfood and packed fixture exercise both integration paths. +- [x] Reconcile the full council and local Opus UX/security evidence; add red-first regressions + for every valid finding. Keep final check receipts and the concise PR body current. + +The implementation was recovered after an external cleanup selected the active checkout. Work +continues in Kevin's reserved node-sdk checkout, with no new clone or worktree. Before review, +img check, verify and all 102 packed Chromium/WebKit cases passed; the corresponding CI is green. +The initial packed failure caught Turbopack excluding .next as an import source; generated nonsecret +options now live under node_modules/.cache, while the catalog stays the single source of truth. +The generated wrapper README is synchronized. A red-first follow-up aligns CLI catalog transport +validation with the renderer's parameter-only and repeated-query options. + +Council's four valid findings are fixed red-first: credential-free scaffolding preserves catalog +delivery and validates an explicit workspace, generated types survive CRLF conversion, and the +phase-aware Next plugin never regenerates build options during production startup. The packed +fixture now prunes those options before next start. Opus UX and security both pass; its alt-text +nudge is added to the CLI output. The corrected full yarn check passes (278 img, 587 Node plus +one existing skip). Final img check → verify → packed fixture, closure review and exact-head CI +remain the handoff gate; their receipts live in the report and PR body, without a docs-only SHA loop. +No merge, publication, API2 restart or new blind-reader round is part of this handoff. + +The closure council exposed a credentialed variant of the endpoint mismatch: optional init could +publish on development while preserving a production catalog, or save the wrong environment's +private key. Four red-first cases cover both origin directions and both side effects; mismatches +now fail under the catalog lock before publication or credential writes. Explicit endpoint changes +move publication and delivery together; matching origins still work. The scaffold generator's +unused private/endpoint branches were removed after confirming it has one public-only caller. +Generated types intentionally stay canonical LF; Git handles checkout conversion. Final receipts +are refreshed after this bounded correction; earlier green CI is not relabeled as its proof. + +Report and commit-stamped evidence: `/tmp/img-task2-round12-report.md`. + +## Round 13 — truthful setup and actionable diagnostics + +Input: /tmp/img-task2-round13-brief.md and both independent Rauch round-9 reports. Their no-init +public setup passed in 4m14 and 3m03 including signup; these are reader timings, not this round's. +Stay in Kevin's reserved node-sdk checkout and #500, private 0.0.0. No merge or publication. + +- [x] Specify Smart CDN plus assemblies:write for private keys and use the origin's error header. +- [x] Name unknown paths, nearby catalog matches and fixes; explain unpublish and dev policy changes. +- [x] Make optional examples use the package import, not a second factory scaffold shape. +- [x] Show sha256 for combined keys in leading SDK examples; keep the existing default unchanged. +- [x] Qualify cache/recovery/host claims and list known limits in the PR body. + +Validation gates: red-first regressions, local checks, packed fixture, independent reviews and +exact-head CI. The PR's Verification section records the current head and receipts after these +gates; /tmp/img-task2-round13-report.md records the full local evidence. + +Report: /tmp/img-task2-round13-report.md. Broader API2/Console changes and deferred product ideas +remain outside this round; no additional clone or worktree is needed. + +Red-first contract evidence: scope/header + docs/CLI regressions, safe unknown-path and unpublish +advice, HMR policy snapshots, package-only scaffold (including custom catalog/owned-code preservation), +and SHA-256/cache/recovery examples. Focused suites are green; check, img check and verify pass +(288 img, 594 Node with one pre-existing skip). Owned Next dev/browser desktop/mobile and real +catalog hot reload pass; /tmp/img-r13-evidence-eGTRmZ contains screenshots and redacted evidence. +Initial head 14a50e9 passed all 102 packed cases first attempt and CI run 34870869766. Opus UX and +security passed. Council's five findings were then reproduced red-first and corrected: reject +outside-app scaffold catalogs before publication, validate paths before shell advice, name the +custom --receipts option and factory updates, give unsigned failures the right private remedy, +and distinguish factory policy from catalog policy. The review also prompted copy-safe commands, +an alt reminder, overwrite recovery advice and phrasing-safe development fallback annotations. +Application authorization exceptions deliberately propagate; callbacks deny by returning false. +The follow-up council's last P3 was fixed red-first: leading-slash/space variants of a known key +retain a safely escaped exact-key suggestion, but are still rejected and never yield upload advice. + +### Round 13 addendum — scope, blur and compact URLs + +Kevin's ordered items 6–9 in /tmp/img-task2-round13-addendum.md, starting at 2779025. GitHub has no +open review comments and origin/main is already an ancestor. Same checkout and PR; no API2 edits, +new worktrees, merge or publication. Append evidence to /tmp/img-task2-round13-report.md. + +- [x] Prefer smart_cdn:sign in private setup/diagnostics; assemblies:write is also accepted. +- [x] Generate bounded ThumbHash receipt metadata on store and server-decoded blur placeholders. +- [x] Omit verified Built-in defaults, preserve custom-template behavior and explicit delivery. +- [x] Describe v as an unverified cache-busting tag and recommend immutable filenames. +- [x] Add red-first coverage and repeatable packed checks; reconcile council and Opus findings. + +API2 source confirms storage-preview@0.0.2 defaults f=jpg, q=75, r=pad and bg=#ffffff; +public-preview@0.0.1 derives from it. Candidate dimensions remain explicit. Cache keys deliberately +change before public release; transparent formats still need their nondefault background. + +ThumbHash encoding/catalog/declarations/server decoding are implemented and unit-tested. Kevin +decided against any client load handler. Store records hasAlpha: true only for original alpha +channels. Transparent images omit the blur background and explain why in development; opaque +images keep it hidden beneath the loaded pixels. The generated types and matching-MD5-only sync +preserve this metadata. Public browser cases explicitly disable JavaScript for both kinds of image. +Private redirect markup deliberately never embeds blurred pixels before image authorization. +Encoding is best-effort and bounded (32 MiB encoded, 40 MP decoded, 100×100 hash input, 2s decoder). +Checksum changes drop the local hash; sync only preserves one with matching MD5. + +Local checkpoint: check + img check + verify pass (306 img, 602 Node + one existing skip). Packed +Chromium/WebKit passes 50 cacheComponents-enabled + 50 omitted + 10 development cases, first +attempt in the final run. The capture's pending-load screenshot was changed to computed-style +evidence: Playwright's font-ready screenshot wait deadlocked while the test held image delivery. +The loaded screenshot remains. All native AVIF/WebP/JPEG, alpha and private-authorization checks +pass. The follow-up alpha regressions failed first (6 failures) and pass after the server-only +decision. The report and PR Verification section record each reviewed head and its final packed/CI +receipt; do not reuse the earlier checkpoint's counts as current evidence. Do not merge or publish; +stop after the verification gates and documentation. + +Review follow-up on 0d8aa74: the strict browser audit exposed Chromium's disabled-script preload +as a CSP failure, independently reproduced with JavaScript off and an enabled control. Record +only that same-origin Next script cancellation as expected; image/network errors still fail. +Council/Opus also exposed quantized ThumbHash ratios leaving permanent bands beside letterboxed +images. Blur now requires opaque box-filling content and uses 100% 100% sizing; other fits opt out. +Hash-header alpha provides defense in depth for older/edited receipts. PNG data input is bounded, +Sharp is optional, and docs explain CSP/data URLs and up to about 6 KB extra inline markup. +Public Built-in boundaries are enforced at model generation (4096 px in both dimensions, quality +<=85), including fallbacks and art-direction crops; private/customer templates keep their limits. +Eleven new regressions failed first, then passed. Repeated checks/review/packed/CI receipts are +tracked in /tmp/img-task2-round13-report.md and the PR's exact-head Verification section. + +### Round 14 — final private-path message polish + +Kevin's six items in /tmp/img-task2-round14-brief.md, based on both round-10 reader reports. +Start at e054145 in /Users/kvz/code/node-sdk, img-onboard, PR #500. Main is already an ancestor; +GitHub has no open review comments. No new product slice, worktree, API2/Console edit or release. + +- [x] Missing credentials name the documented Smart CDN pair and Console preset. +- [x] A development authorizer added after bundling explains the required restart. +- [x] Store advice follows known publication/metadata, with private files/key/restart and no blur. +- [x] README includes the small private recipe and create-next-app's typed config shape. +- [x] Store reads Next config to show an absent wrapper, without executing or editing it. +- [x] S3 403 advice names its endpoint and both disabled/denied possibilities. +- Validation gates: red-first tests, img check, verify, packed fixture, review and exact-head green + CI. Per-run receipts and the final result live in the report and PR body, not a stale badge here. + +Evidence and final handoff: /tmp/img-task2-round14-report.md. Keep production S3-read, hosted +Vercel and real Bunny verification as release gates; do not claim this local polish closes them. + +Red-first: 19 intended failures, then a separate HEAD-403 failure; all pass after the fixes. +Check, img check and verify pass (319 img, 615 Node plus one pre-existing skip). The cold-source +script guard caught a transitive workspace import during implementation; snippet helpers remain +usable without built packages. README grows to 91 lines to include the requested private recipe. +No dependency or lockfile changes. Packed browser, independent review and exact-head CI follow. + +The 98bcee8 live Next probes confirmed late-authorizer restart and missing-key messages; owned +desktop/mobile public/private browser and exploratory checks passed. Opus UX passed. The initial +packed/CI attempt exposed a stale seed assertion demanding blur for an intentionally transformed +upload whose receipt has no hash; its JSX assertion now matches the metadata, with an explicit +absent-hash assertion. Council/Opus's three in-scope refinements were reproduced red-first and fixed: +explain CommonJS-to-mjs migration before ESM config advice, do not infer destination privacy from a +foreign catalog left unchanged, and print shared setup once per upload batch. Check, img check and +verify pass again (319 img, 618 Node plus one pre-existing skip). Full packed and exact-head CI +results are recorded in /tmp/img-task2-round14-report.md and the PR Verification section. + +Deferred outside Kevin's private-message-only scope: council found an existing public-utility bug +in packages/utils/src/node.ts getSmartCdnImageCandidates (last touched at 3e473f4, not round 14). +With builtin/public-preview@0.0.1, widths [400] yields h=8000 without dimensions, or h=6000 for a +400x6000 source, above that Built-in's 4096px limit. Read-only reproduction is in +/tmp/img-r14-deferred-utility.log. No packages/img or packages/node caller uses this helper; +StorageImage's separate model already applies public limits. This is real, not fixed or claimed +safe. Correct the generic helper's template-specific bounds separately before advertising it for +the public Built-in; preserve custom-template behavior and truthful width descriptors. No extra +issue or public-delivery implementation was started in this polish round. Also keep the established +hosted Vercel, registry install and actual Bunny/S3 rollout gates. Stop; do not merge or publish. + +### Round 15 — content-addressed upload names and focused recovery docs + +Kevin's two items in /tmp/img-task2-round15-brief.md. Starting at c56ec58 in the reserved +/Users/kvz/code/node-sdk checkout, img-onboard, #500. Latest main is already an ancestor; no GitHub +review threads. No other repo changes, new worktrees, merge, release or follow-on round. + +- [x] Add storage store --hashed: eight MD5 hex digits before the extension; catalog, generated + path types and JSX agree, with the original local filename retained as source. +- [x] Prove same-byte replays skip uploads using a same-workspace catalog and complete checksum/ + size match; changed bytes get another name and short-hash conflicts never overwrite. +- [x] Move S3 recovery commands out of the README to the explicitly unavailable reference section. +- Verification gates: red-first tests, a packed hashed upload rendered in browsers, required + checks, focused review and exact-head green CI. Final receipts/status live in the report and + PR body; do not use an earlier head's CI badge. Stop after these gates, without merging/releasing. + +Evidence and final state: /tmp/img-task2-round15-report.md. Existing hosted/release gates and the +deferred generic public-utility finding above remain unchanged. + +Red-first: 16 failures / 92 passes, then 107 / 108 passed and the remaining replay test exposed +catalog key reordering. Replays now preserve the original record/object rather than writing the +schema's reordered projection. CLI hashing uses a bounded preflight read so a repeat can skip the +SDK/Assembly entirely; fresh uploads retain the SDK's independent checksum/receipt verification. +No new public SDK options or dependencies. Matching-checksum sync preserves source; the default +unhashed path and overwrite behavior stay unchanged. No production S3 availability is assumed. + +Review follow-up on 222aff3: council identified same-slug cross-API replay, repeated uploads under +an explicit foreign-workspace override, transformed-receipt retry advice and misleading S3 examples +in both SDK READMEs. Twelve assertions failed first. Hashed receipts now record apiOrigin and only +skip in that API environment; a per-invocation verified-receipt map also deduplicates override +batches without changing foreign catalogs. Transformed/colliding bytes fail without writing and +explain why restoring that same receipt cannot help. SDK README examples now link to gated recovery; +the legacy wrapper is regenerated, not hand-edited. Opus's valid wording fix distinguishes Git +recovery from the unavailable S3 commands; hash advice precedes the first upload. + +The packed seed's initial CI failure was test isolation: a new credentials-file path in the same +CLI cwd was correctly treated as an untrusted override. Its independent CLI cwd preserves the +ownership check and copies verified metadata back into the browser app. All 11 seed tests pass in +the isolated reproduction. Packed builds/browsers and exact-head CI must still pass after these +fixes; final per-run receipts live in the report and PR body. + +Opus post-fix review confirms all four council fixes and the README wording. Its last adjacent +finding was valid too: the post-write CLI recovery error still printed unavailable S3 commands +without the prerequisite. A focused regression failed first; the error now names the availability +limit and points to Console/verified catalog recovery. No new product slice or broad refactor. + +### Independent DX follow-up — 2026-09-15 + +Kevin approved the three bounded documentation improvements from the fresh Astra review, followed +by an explicit Uppy replacement-policy check. Preserve the product API; do not restart the reader +loop or infer merge, publication or production-rollout authority. + +- [x] Make CLI login the first action even without an account; signup continues in its browser flow. +- [x] Add a discoverable application-server `storeImage()` recipe: Assembly-enabled credentials, + SHA-256 for new combined keys, server-chosen unique paths and persistence with the verified owner. +- [x] Explain that fill `style`/`className` target the image, and constrain the parent in the + responsive example. Native browser geometry was checked during the independent review. +- [x] Strictly typecheck the new upload snippet against the packed candidate. The first check caught + optional environment values; the explicit missing-credentials guard makes it pass without casts. +- [x] Verify Uppy store parameters against the local API and read back the resulting bytes. Omitting + `overwrite` replaces the original; `error` rejects with `TRANSLOADIT_STORE_CONFLICT` and preserves + it; `rename` stores a second file and preserves both. Existing helper tests also pass (5/5). + +Application-image recipes should use server-owned unique paths and refuse collisions. The existing +Uppy user-upload recipe already specifies `conflict_strategy: 'error'`. No manager-mode default was +changed: choosing replacement as a file-management feature is a separate product decision. This +was a real Uppy parameter-builder → Assembly → stored-bytes check, not a browser/Companion proof. +Only disposable local-test assets in the dedicated dev bucket were touched. + +The SDK's required check passes (319 img, 636 Node plus one existing skip); README contract tests +were updated and the legacy wrapper README is regenerated from the canonical source. Final verify +and exact-head CI receipts belong in the PR Verification section and +`/tmp/dam-dx-followup.VZ1bC7/report.md`. The independent review remains at +`/tmp/dam-dx-astra-mZadsP/report.md`. Hosted Content/native-delivery, registry-install and API2 +deployment/S3 rollout gates above remain open; this docs follow-up does not close them. diff --git a/knip.ts b/knip.ts index c398c1c9..f21a6d5d 100644 --- a/knip.ts +++ b/knip.ts @@ -74,12 +74,14 @@ const config: KnipConfig = { ignore: [...sharedIgnore, alphalibIgnore], ignoreDependencies: [ // Used by generated compatibility package sources that are absent in fresh CI checkouts. + '@aws-sdk/client-s3', '@transloadit/sev-logger', '@transloadit/utils', 'cacheable-lookup', 'clipanion', 'debug', 'dotenv', + 'execa', 'form-data', 'got', 'into-stream', @@ -90,6 +92,8 @@ const config: KnipConfig = { 'p-map', 'p-queue', 'recursive-readdir', + 'sharp', + 'thumbhash', 'tus-js-client', 'typanion', 'type-fest', diff --git a/package.json b/package.json index 5e331a85..1be6590c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "pack": "node scripts/pack-transloadit.ts", "parity:transloadit": "node scripts/prepare-transloadit.ts && node scripts/fingerprint-pack.ts packages/transloadit --ignore-scripts --quiet --out /tmp/transloadit-after.json && node scripts/verify-fingerprint.ts --current /tmp/transloadit-after.json --diff", "test:img:fixture": "node scripts/test-img-next-fixture.ts", - "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/img test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", + "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts ./scripts/knip.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/img test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", "test:types": "yarn workspace @transloadit/zod test:types", "test:e2e": "yarn workspace @transloadit/node test:e2e", "test": "yarn workspace @transloadit/node test", diff --git a/packages/img/README.md b/packages/img/README.md index a01ad971..a5c006b7 100644 --- a/packages/img/README.md +++ b/packages/img/README.md @@ -1,207 +1,93 @@ # `@transloadit/img` -Responsive previews of Transloadit Storage objects, delivered through Smart CDN. +Responsive Storage images for Next.js. Native `picture/srcset`; bytes go straight from +Smart CDN (`.tlcdn.com`) to the browser, never through Next's image optimizer. -The package renders native ``, `srcset`, and `` elements. Image bytes travel directly -from Smart CDN to the browser; they are never optimized or proxied by the Next.js application. -Remote HTTP URLs are deliberately outside this package's source contract: an image must already -belong to the configured Transloadit Storage workspace. +## Quickstart -This workspace remains private at version `0.0.0` while the API and production dogfood soak. Do not -depend on it from npm yet. +Next.js 16.3.3+ App Router, React 19, Node.js runtime. +**Unpublished dogfood:** ask a maintainer for matching img/utils/node/types tarballs. +After release: `npm install @transloadit/img && npm install --save-dev @transloadit/node`. +pnpm: `pnpm add @transloadit/img && pnpm add -D @transloadit/node`; +Yarn: `yarn add @transloadit/img && yarn add -D @transloadit/node`. -## Next.js +Run beside `package.json`. Start with `auth login` even without an account: choose Sign up in the +browser it opens, create a free workspace, and approve the CLI. For `./hero.jpg`, use any JPEG you have. -The server entry point targets the Next.js 16 App Router with `cacheComponents: true` in -`next.config.ts`. +Prefer `--hashed` for images you will replace: it generates an immutable filename such as +`website/hero.fce9d56a.jpg`. The command below includes it; use the printed JSX path, with no `--overwrite`. -Create one server-only application module. The factory does not read environment variables: - -```tsx -import { createTransloaditImage } from '@transloadit/img/next/server' - -const authKey = process.env.TRANSLOADIT_KEY -const authSecret = process.env.TRANSLOADIT_SECRET -const workspace = process.env.TRANSLOADIT_WORKSPACE +```bash +npx transloadit auth login +npx transloadit storage store ./hero.jpg website/hero.jpg --public --hashed +``` -if (!authKey || !authSecret || !workspace) { - throw new Error('Transloadit image credentials are required') -} +`--public` publishes the directory recursively, including future uploads. In `next.config.ts`: -export const { Image } = createTransloaditImage({ - authKey, - authSecret, - storage: { allowedPathPrefixes: ['website/'] }, - workspace, -}) +```ts +import type { NextConfig } from 'next' +import { withTransloaditImages } from '@transloadit/img/next/config' +const nextConfig: NextConfig = { /* your existing Next config */ } +export default withTransloaditImages(nextConfig) ``` -The Auth Secret stays in the server module and never enters rendered markup or a client bundle. -Signed browser URLs contain the public Auth Key identifier, as required by Smart CDN verification. - -Use a relative Storage object path as `src` and provide the source's intrinsic dimensions: +Render in `app/page.tsx` or any Server Component. If your app has `src/`, prefix the source paths: +Use the path printed by your upload as `src`; the hash below is only an example. ```tsx -import { Image } from '../lib/transloaditImage.tsx' - +import { StorageImage } from '@transloadit/img/next' export default function Page() { - return ( - A canal house - ) + return } ``` -`storage.allowedPathPrefixes` is a hard workspace boundary, not object authorization. Prefixes must -be relative directories ending in `/`. The default is deny-all; `['']` deliberately allows the -workspace root. Paths with dot segments, backslashes, empty segments, control characters, -non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signing. +Run `npm run dev` and open `/`. Commit `transloadit.images.json` and `transloadit-images.d.ts`, then deploy. +Public images need no app secrets. The required plugin bundles the catalog, with no runtime lookup. -### Direct delivery +## Responsive -Direct delivery is the default and fits image-heavy views that already authorize their data while -rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A -built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image -itself is request-rendered and must not be stored in a shared full-page cache. -`suspenseFallback` customizes that shell. +Catalog paths autocomplete. `width` sets a responsive maximum; `preload` makes a hero eager, +preloads its responsive source and sets high fetch priority. Other images load lazily. +Add `placeholder="blur"` for an inline preview from the receipt's optional `thumbhash`. +[Layouts, art direction and the temporary priority alias](./docs/reference.md#responsive). -The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the -platform default. A candidate first requested after its signature expires can fail on an unusually -long-lived page; choose an appropriate bounded `expiresInMs`, eagerly load a measured critical -image, or use authorized redirect delivery. +## Private -### Authorized redirects - -Redirect delivery keeps markup stable and rechecks application access when the browser loads an -image: - -```tsx -import { createTransloaditImage } from '@transloadit/img/next/server' - -export const { Image, storageRoute } = createTransloaditImage({ - authKey, - authSecret, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { - authorize: async ({ path, request }) => { - const user = await authenticate(request) - return user !== null && (await canReadStorageObject(user, path)) - }, - // Match next.config.ts when the application uses basePath. - basePath: '/app', - route: '/api/private-images', - }, - }, - workspace, -}) -``` - -Export the handler from that exact App Router path: +Wire `getSession` to your application's session and per-object permissions; it is not an SDK helper: ```ts -export { storageRoute as GET } from '../../../lib/transloaditImage.tsx' -``` - -The component emits same-origin URLs containing an authenticated-encrypted capability for one -exact Storage path and transformation. Filenames and credentials stay out of prerendered HTML. -The handler rejects changed, duplicate, unknown, oversized, or malformed capabilities before -calling application authorization. `authorize` must return the boolean `true` for the current -request. - -After authorization, the handler returns a private, non-cacheable `307` to a fresh signed Smart CDN -URL. Image bytes still bypass Next.js. Rotating the Transloadit secret invalidates existing -capabilities, so redeploy cached static markup at the same time. - -| Property | Direct, the default | Authorized redirect | -| --- | --- | --- | -| Next.js work per loaded image | None | One authorization + redirect | -| Image bytes through Next.js | Never | Never | -| Shared/static image markup | No | Yes | -| Request-time revocation | No | Yes | -| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load | -| Typical fit | Large authorized galleries | Strict ACLs and revocation | - -## Responsive policy - -Storage previews use signed-only `builtin/storage-preview@0.0.1`. AVIF quality 45 and WebP quality -75 are emitted in browser preference order, with a JPEG quality 75 fallback. Explicit formats keep -CDN objects independent from an unkeyed `Accept` header. - -The default candidate ladder is 320, 640, 960, 1280, 1920, 2560, and 3840 pixels, capped at the -declared intrinsic width and backend-safe height. The exact intrinsic width is included between -steps. `widths` is an advanced per-image override. `sizes` is optional because that is valid HTML, -but strongly recommended whenever an image is not effectively `100vw`. - -```tsx -Product photo +// transloadit.authorize.ts, beside next.config.ts +import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server' +import { getSession } from './lib/authorization' +export const authorize: AuthorizeTransloaditStorageImage = async ({ path, request }) => + (await getSession(request))?.canRead(path) === true ``` -- Images are lazy and asynchronously decoded by default. -- `preload` implies eager loading. Combine it with `fetchPriority="high"` only for a measured LCP - image. Explicitly lazy preloads are rejected. -- `objectFit` is forwarded for deliberate crop or containment behavior. -- `deferUntilHydrated` avoids WebKit parser-to-hydration replay for non-critical images. It cannot be - eager or preloaded and is not a secrecy mechanism. -- `fallbackQuality` changes the signed JPEG fallback quality. - -Private signature lifetimes default to at least one hour in stable five-minute rotation windows. -Their sum cannot exceed 48 hours: - -```tsx -storage: { - allowedPathPrefixes: ['documents/'], - expiresInMs: 2 * 60 * 60 * 1000, - rotationIntervalMs: 5 * 60 * 1000, -} +```ts +// app/api/storage-images/route.ts (prefix with src/ if needed) +export { GET, HEAD } from '@transloadit/img/next/route' ``` -## Template override +Console → Credentials → New Auth Key → “Private image delivery”: Smart CDN on, `smart_cdn:sign` +(`assemblies:write` is also accepted, but grants broader Assembly access). Use this application key, +not the disposable login key that `auth logout` revokes, in `.env.local` and your host's server-only build/runtime env: -A compatible workspace Template can replace the Built-in in trusted factory configuration: - -```tsx -export const { Image } = createTransloaditImage({ - authKey, - authSecret, - storage: { allowedPathPrefixes: ['website/'] }, - template: 'my-storage-preview', - workspace, -}) +```dotenv +TRANSLOADIT_SMART_CDN_KEY=… +TRANSLOADIT_SMART_CDN_SECRET=… ``` -Template selection is unavailable on individual images because the factory owns the signing -boundary. A replacement must accept the same trusted fields as the Storage preview Built-in. +Restart `next dev` after adding the authorizer. [Private setup and authorization contract](./docs/reference.md#private). -## Framework-neutral API +## When it breaks -`@transloadit/img` exports `createTransloaditImageModel` and serializable model types. -`@transloadit/img/next` renders an already-resolved model. These lower-level entry points let other -framework adapters inject a server-side URL resolver while credential and authorization policy stay -outside the renderer. +Opt into `errorFallback` to show a delivery failure instead of a broken image. Development adds +the HEAD result; see the terminal. Non-production login endpoints carry into the catalog. +[`baseUrl` and `urlParams`, diagnostics and recovery](./docs/reference.md#when-it-breaks). +Lost metadata? First restore the committed catalog; see [recovery options and API availability](./docs/reference.md#recovery-requires-the-storage-read-api-not-yet-enabled-in-production). -## Verification +## Reference -```console -corepack yarn workspace @transloadit/img check -corepack yarn test:img:fixture -``` +[Store an image from your application server](https://github.com/transloadit/node-sdk/blob/main/packages/node/README.md#store-an-image). -The fixture packs the published artifacts, installs them into a clean Next.js 16 App Router app, -builds partially prerendered and dynamic routes, starts the production server, probes route -authorization and capability tampering, checks for secret leakage, and reports direct-versus- -redirect HTML size and route work for 1, 20, and 100 images. Size measurements are deterministic; -wall-clock measurements are diagnostic and do not create flaky CI thresholds. +[Full reference](./docs/reference.md) · [Cache tags do not pin old bytes](./docs/reference.md#cache-and-markup-cost) · [Maintainer dogfood setup](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md). diff --git a/packages/img/docs/reference.md b/packages/img/docs/reference.md new file mode 100644 index 00000000..7389ef16 --- /dev/null +++ b/packages/img/docs/reference.md @@ -0,0 +1,807 @@ +# Storage images reference + +Start with the [Quickstart](../README.md). This reference covers policy, advanced layouts and operations. + +## Next.js plugin and catalog convention + +`StorageImage` from `@transloadit/img/next` is an App Router Server Component. Wrap the existing +Next config with `withTransloaditImages(nextConfig)` from `@transloadit/img/next/config`. +The plugin binds `transloadit.images.json` and optional `transloadit.authorize.ts` from the app +root using build-time aliases for Turbopack and webpack, and adds narrow output tracing includes. +It preserves existing aliases, tracing rules and webpack hooks. Restart dev after first adding +the authorizer; ordinary catalog edits participate in the bundler's dependency graph. +The wrapper returns Next's phase-aware config function; make it the outer wrapper when composing +with plugins that accept only config objects. Generation runs in dev/build, never at `next start`: +the compiled app does not need the source catalog or generation cache to remain on disk. + +This first cut requires the plugin. There is no cwd-based runtime fallback: bundlers and deployment +hosts differ in which files they trace and where they start a process. The packed fixture verifies +local `next build` + `next start`, with Cache Components enabled and omitted. Hosted Vercel, +other serverless adapters and Edge are not claimed as verified; Edge is unsupported. + +```ts +import { withTransloaditImages } from '@transloadit/img/next/config' + +export default withTransloaditImages({}, { + catalog: 'assets/transloadit.images.json', + // Set root: import.meta.dirname when starting Next from a monorepo parent directory. + delivery: { + baseUrl: 'https://my-storage-api.example/file/{workspace}', + urlParams: { cdn: 'required' }, + }, +}) +``` + +Catalog overrides must remain inside the app root. The plugin's generated options under +`node_modules/.cache/transloadit-images/` contain only nonsecret transport/basePath settings, never a +second catalog or an application key. Its build-time delivery override wins over catalog delivery. +Rebuild after changing transport, private authorization or deployment keys for prerendered pages. + +`storage store` creates the catalog on first use and updates it after each successful upload. +Run beside `package.json`, or select a catalog explicitly with `--receipts`. The catalog contains +workspace, server-declared public prefixes and image receipts. A non-production login also writes: + +```json +{ + "delivery": { + "baseUrl": "http://127.0.0.1:3020/file/{workspace}", + "urlParams": { "cdn": "required" } + } +} +``` + +This is a field within the catalog, not a standalone catalog. Production logins omit it. +Later writes preserve an existing delivery choice; remove that block deliberately to return to +production Smart CDN. Neither the catalog nor declarations contain login credentials. + +### Generated types and optional scaffolding + +CLI catalog writes also derive `transloadit-images.d.ts` beside the catalog. Commit both files. +Its augmentation of `RegisteredStorageImages` in `@transloadit/img/next` gives `src` exact path +completion and retains each source's width/height. Next's stock TypeScript include discovers it; +include the declaration explicitly if your app uses restrictive includes. Without it, `src` is +`string` and runtime geometry still comes from the JSON. The declaration is metadata, never a +second runtime source. Recovery regenerates it without another upload. + +Use one conventional catalog per app. For several catalogs, keep their explicit +`createStorageImages(catalog)` factories and inferred JSON keys instead of combining generated +global declarations. Factories do not require the plugin. They also understand catalog delivery; +explicit top-level `baseUrl`/`urlParams` override that block. + +`image init website/ --example` remains an optional page generator using the same package import +as the README, without `lib/storageImage.ts` or a second factory. It uses an existing +catalog without login or publication, preserving its workspace and delivery. A saved development +login cannot redirect that existing catalog; only an explicit `--endpoint` changes its transport. +Credentialed init (publication or `--write-env`) requires matching login/delivery origins; otherwise +it refuses before any write. Select matching credentials or a separate catalog, or deliberately +switch delivery with `--endpoint`. Custom CDN overrides can stay in the Next config plugin instead. +`image init uploads/ --private` creates only the conventional +authorizer and route below; add `--example` for a page too. The older `image init --public` is an +explicit publication plus example shortcut, not a prerequisite. No existing source file is overwritten. +Package-import scaffolding requires a catalog inside the Next.js app; external catalogs are refused +before writing files or publishing. Use an explicit `createStorageImages` factory for shared catalogs +outside the app instead. + +## Responsive + +`preload` is this component's hero macro: eager loading, a responsive preload and high fetch priority. +Unlike Next.js 16's preload flag (which adds a preload link), it intentionally sets all three. +Next deprecates priority in favor of preload; our `priority` alias remains for one release and +warns only in development. Do not combine either with lazy loading. Explicit eager `sizes="auto, …"` +uses the fallback lengths without `auto`, with a development warning; bare `auto` falls back to 100vw. +Other images default to native lazy loading. Props are serializable native attributes, not callbacks or refs. + +`placeholder="blur"` uses the receipt's optional base64 `thumbhash`. `storage store` and +`client.storeImage()` generate it from the checksum read using pinned [ThumbHash](https://github.com/evanw/thumbhash) +and Sharp, EXIF-oriented and at most 100×100 pixels. Encoding is best-effort: originals over +32 MiB, over 40 million pixels, unsupported formats or a two-second decoder timeout omit it. +Origin-side byte changes also omit the hash, since the local preview would no longer match. +The Server Component decodes the hash; the ThumbHash decoder never enters the client bundle. +Sharp is an optional SDK dependency; an unavailable local decoder omits this metadata without +blocking the Storage write. Storage writes also record `hasAlpha: true` only when the original has an alpha channel, even if +all its pixels happen to be opaque. For those images blur is a no-op with the development-only +note "transparent image: no blur placeholder". An alpha-encoded hash also suppresses blur when +the receipt flag is missing. For images without alpha, the background remains in place, hidden +under the loaded opaque image: no client-side load handler is needed or shipped. +Blur requires a box-filling image: the default constrained layout, or `object-fit: fill` / `cover`. +Letterboxed `contain`, `none` and `scale-down` images omit it with a development note, since the +approximate ThumbHash ratio could otherwise leave a permanent blurred band beside the image. +Each blur adds up to about 6 KB of inline PNG/base64 markup before HTML compression; opt in only +where the loading preview is worth that extra HTML. Your CSP must allow `img-src data:` (alongside +your normal image sources) for the placeholder to display. +Without a usable hash, the prop is a no-op with a development-only note. Request-authorized +private redirects also omit it: embedding blurred private pixels would expose them before the +image request's authorization check. Direct delivery is only for already-authorized page data. +Receipts sync performs no original download and cannot create a missing ThumbHash. +ThumbHashes contain a recognizable preview, not just a checksum. Keep catalogs for private images +in private source control, or remove their `thumbhash` fields before sharing the catalog publicly. + +The `constrained` and `fixed` layout names follow Astro; `fill` follows Next.js. + +The pinned public Built-in caps both output dimensions at 4096 pixels, including crops and JPEG +fallbacks; the SDK scales its candidate ladder accordingly. Explicit public quality above 85 is +rejected before rendering. Private previews retain their 8000-pixel / quality-100 limits. + +`width={960}` on a catalog path or receipt derives proportional responsive CSS, the +`auto, (min-width: 960px) 960px, 100vw` sizes expression for lazy images and a ladder capped at +1920px and the source. Eager/preloaded images omit `auto`. Explicit `sizes` remains your override. + +```tsx + +``` + +Fixed layout keeps intrinsic dimensions in `src`; `width` and `height` describe the display box. +It derives `sizes="48px"`, 48/96px candidates and a 48px JPEG fallback. `fit="cover"` requests a +`fillcrop` at the box ratio, so a square avatar does not download an uncropped original. +The default `fit="contain"` keeps the source proportions with CSS letterboxing. + +```tsx + +``` + +Fill with `aspectRatio` emits a positioned container with that ratio and crops to match it. +Native `style` and `className` apply to the image, not that container. Constrain the parent to set +the frame's maximum width; setting `maxWidth` only on the image leaves the frame full-width. +Use `frame={false}` when your application already owns the box, matching its CSS to the crop. +Fill without a ratio requires an already-sized, positioned parent; cover always needs the ratio. +All layout modes preserve explicit `sizes`, `widths`, `style` and `objectFit` overrides. Source +and backend limits still apply. `widths` overrides even the constrained mode's default 2× cap. +`layout="none"` retains presentation-only width/height; its encoding strategy stays `pad`. + +For different mobile and desktop crops, pass width breakpoints in priority order and a default: + +```tsx +
+ +
+``` + +The same map sizes the emitted container; no duplicate responsive CSS is needed. Each breakpoint gets +real `fillcrop` candidates and its own JPEG fallback; preloads select only the matching crop. +Use up to eight `(min-width: …)` or `(max-width: …)` conditions with px, em or rem. No oversized +viewport-width arithmetic is needed. Receipts always supply intrinsic signing geometry; +separate `width`/`height` props describe presentation, even without fixed layout. One presentation +dimension derives the other proportionally. + +## Private + +Keep private uploads in a never-published directory such as `uploads/`. Removing a JavaScript +public declaration does not revoke server policy or recall cached bytes. Create the separate +application key described in [Login and credentials](#login-and-credentials), not the CLI login key. +Set that pair in both the host's server-only build and runtime environments, never as `NEXT_PUBLIC_`. + +```ts +// transloadit.authorize.ts, beside next.config.ts +import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server' +import { authenticate, canReadStorageObject } from './lib/authorization' + +export const authorize: AuthorizeTransloaditStorageImage = async ({ path, request }) => { + const user = await authenticate(request) + return user !== null && (await canReadStorageObject(user, path)) +} +``` + +`request` is a standard Web `Request`; read the browser's native cookie through your session library. +Export in `app/api/storage-images/route.ts` (prefix source paths with `src/` if your app uses it): + +```ts +export { GET, HEAD } from '@transloadit/img/next/route' +``` + +The default route is `/api/storage-images`; denied requests return `404`. Each uncached private +image load invokes the handler once. Conventional private redirects use private, no-store responses. +Downstream CDN grants have 30–60 minutes remaining by default and are usable +until expiry, independently of redirect caching. Image bytes always bypass the application. + +`image init uploads/ --private` scaffolds those two files with fail-closed authorization, including +in an existing public project. It never overwrites application code. The optional `--write-env` copies the saved login key +for local testing only; replace it with the separate application key before deployment. +Public-only rendering never reads or validates signing credentials and needs no application env. + +For custom routing or caching, keep the explicit factory escape hatch: + +```ts +import { createStorageImages } from '@transloadit/img/next/server' +import catalog from '../transloadit.images.json' +import { authorize } from '../transloadit.authorize' + +export const { StorageImage, storageRoute } = createStorageImages({ ...catalog, authorize, cacheMaxAge: '1m' }) +``` + +Its route exports `storageRoute as GET, storageRoute as HEAD` from the application factory. +This optional minute of redirect caching delays reauthorization; omit it for private, no-store. + +## Mixed public and private images + +Spread the committed catalog alongside `authorize` to share one factory. Published paths always emit +direct unsigned URLs, with zero application image requests; private paths still emit capabilities +and use the authorizer. Public prefixes are also allowed prefixes when no explicit `allowedPathPrefixes` is supplied, +including for an empty catalog. An explicit allowed policy still bounds public prefixes; the workspace +root cannot be declared public. CLI commands maintain `public` in `transloadit.images.json` after +updating server policy; do not edit that field manually: + +```bash +npx transloadit storage publish website/ +npx transloadit storage publish website/ --dry-run +npx transloadit storage publications +npx transloadit storage unpublish website/ +``` + +Publishing is idempotent and requires `dam:write`. `storage store ./hero.jpg website/hero.jpg --public` +declares the destination directory after checkpointing the upload. It prints the recursive +current-and-future publication boundary before changing it. If publication fails, the receipt +remains saved: retry with `storage publish website/`, not another upload. A root object cannot be +published with --public. Plain `storage store` never changes access policy. +`--dry-run` only lists current matching objects through the S3 read API; it never publishes or +changes the local catalog. Future objects under that prefix would also be public after publication. +Unpublishing stops uncached origin access. Cached or downloaded bytes cannot be recalled. + +## When it breaks + +In development only, the server performs one HEAD per unique path/Template per factory, with a +five-second timeout. The probe runs in the background and never holds up the image or redirect. +An opted-in development failure fallback shares that same result (origin/path only, no query) and +points at the terminal; it issues no additional HEAD. Production fallback output is unchanged. +Concurrent/repeated renders share that probe. Redirects probe only after +application authorization; disallowed prefixes fail before any request. Production performs no +diagnostic requests. Editing the factory configuration recreates its probes through Next.js Fast +Refresh; repeated requests to the unchanged factory do not retry a failed check automatically. + +Denied redirect routes also emit one development-only hint per reason (and verified path for authorization): route/basePath mismatch, +invalid or stale capability (secret/Template changes), disallowed prefix, or failed authorization. +Malformed capabilities never reveal a requested path. After decoding a valid capability, denied +authorization names the catalog path and offers `storage publish` only if that image should be public; +it never changes policy. URLs, signatures and secrets are not logged. When the conventional catalog's +`public` policy changes during development, one server notice lists paths that now require the +private route and authorization. Unknown catalog keys name the path, suggest a close spelling, and +show a safely quoted `storage store` command. Set `basePath` explicitly in the factory +if your Next.js app uses one; no internal Next environment variable is consulted. +Private direct delivery logs once per factory that it makes the route dynamic; public direct does not. + +An unsigned public HEAD with `Transloadit-Error: NO_SIGNATURE_FIELD` (HTTP 400) gets a +`transloadit storage publish` hint. Other HTTP 400 responses receive generic endpoint/Template +advice; 404 points to the workspace, path or Template. Older API versions without the header get +the generic hint, not an inferred publication diagnosis. A 200 image response with +`immutable` confirms the public delivery/cache contract. +If that path should be private, remove its stale public declaration from the catalog or factory +and configure private delivery; an authorization callback cannot gate a path still declared public. +Recovery commands target the default catalog unless you add `--receipts ` for your +custom catalog. With an explicit factory, update its images/public configuration as well. +The probe reads the `Transloadit-Error` code before choosing advice. `INSUFFICIENT_AUTH_SCOPE` +calls for `smart_cdn:sign`: edit the application key in Console → Credentials, +with Smart CDN enabled (`assemblies:write` is also accepted, but grants broader Assembly access). Other safe error-code +labels are included in the HEAD result. Only a 403 without a specific code leaves Smart CDN +enablement, workspace, secret, expiry and clock ambiguous. No response bodies, raw errors, +signed query strings or secrets are logged. +The probe can trigger one cold transformation in development; it does not weaken authorization. + +```tsx +Image unavailable

} +/> +``` + +This optional small client boundary keeps the exact server-rendered picture and replaces it only +after a failed native image load, including one completed before hydration. It adds no wrapper +element or retry loop. A changed source remounts the boundary. For a same-page sign-in followed +by `router.refresh()`, the **experimental** `retryKey` option accepts a non-secret session identity, for example +`retryKey={user?.id ?? 'anonymous'}`. The refreshed Server Component then resets a failed image +even though its redirect URL has not changed. A deliberate retry counter also works. Refreshing +alone does not reset a stable failed boundary, and unchanged keys never cause retry loops. +With no JavaScript, native image +failure behavior remains. A cross-origin browser error cannot identify the HTTP failure reason. +This is separate from `suspenseFallback`, which handles pending server signing, and from JPEG +format fallback, which does not recover failed AVIF/WebP requests. + + +### Login and credentials + +`auth login` creates a short-lived device authorization, prints its code and verification URL, +opens your browser on macOS/Linux/Windows and polls until you approve the workspace. Open the +printed URL manually if opening fails. Windows uses `cmd /c start ""` with a safely quoted URL and no +AutoRun/delayed expansion. `--no-browser` only skips the +browser launch. Ctrl-C cancels polling without saving anything. Secrets never pass through the +browser URL or a localhost callback. A browser denial stops polling immediately and saves nothing. +The approval page lets you sign up before choosing a workspace; +the code remains valid for 15 minutes while you verify your email and finish signup. + +The approved **Auth Key** appears under the Console's +**[Credentials](https://transloadit.com/c//template-credentials/)** sidebar item and supports +Assemblies/Storage writes and Smart CDN. Existing keys used for private rendering also need +Smart CDN enabled and the `smart_cdn:sign` scope (`assemblies:write` is also accepted). + +For private deployments, create a **separate application key** in Console → Credentials → New Auth Key +with Smart CDN on and the `smart_cdn:sign` scope (`assemblies:write` is also accepted). +The signing-only scope permits URL transforms, not standalone Assembly or Storage writes. +Set `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET` in the host's +server-only build and runtime environment, using the same pair for the page and route handler. +If you also grant Assembly access and use that combined key with `new Transloadit()` from `@transloadit/node`, pass +`signatureAlgorithm: 'sha256'`: new Console-created combined keys use SHA-256, while the SDK keeps +its SHA-384 default for existing keys. The image component already signs Smart CDN URLs correctly. +`TRANSLOADIT_SMART_CDN_KEY/SECRET` override the pair, not individual missing fields. Keeping the +application key separate prevents a developer's logout from breaking deployed images: `auth logout` +revokes the browser-login key. Never deploy that disposable login identity as the application's key. + +Login saves `TRANSLOADIT_WORKSPACE`, `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET` in +`~/.transloadit/credentials` with owner-only permissions. A shell `TRANSLOADIT_CREDENTIALS_FILE` +override is supported; project dotenv cannot redirect newly authorized credentials. +Existing credentials are preserved: login prints their file path, saved workspace/description and +file modification date in UTC. Set `TRANSLOADIT_CREDENTIALS_FILE` to another file for a separate +login, or deliberately use `--replace`; app env files and symlinks are refused. +`auth status` prints the saved workspace and key description without secrets. `auth logout` +revokes a browser-login key before removing the credentials file, ignoring stale shell/project +keys. Applications using that same key stop working too. Imported (`--stdin`) and legacy keys +are only forgotten locally; use `auth logout --revoke` to explicitly revoke those shared keys. +If revocation fails, the file remains so you can retry. `DELETE /auth_keys/self` identifies +the signing key on the server; no stored key ID or broad key-management scope is needed. +Revocation propagates through API caches asynchronously; logout is not an instantaneous global +cutoff. `--no-revoke` is rejected without changing the key or credentials file. +After saving, login makes one bounded signed `GET /storage/public_prefixes` to verify `dam:write` +and catalog access without publishing anything. Failure preserves the login and prints a Console +link plus a retry command. Success does not prove worker/object-store upload availability. +`auth login --stdin` retains automation with dotenv input (workspace optional, but needed by init), +verifying one signed Template read. Never pass secrets as CLI arguments. +The login also saves its API signing algorithm. For combined keys this is SHA-256; the CLI uses +it for subsequent API requests. With `--stdin`, include `TRANSLOADIT_SIGNATURE_ALGORITHM=sha256` +for such a key. Existing credentials without this value retain the SDK's SHA-384 default. +Unrestricted keys (`signature_algo: null`) also retain that default for API requests. + +`image init` is optional: `--example` uses an existing catalog without credentials; publication or +initializing an empty project prefers the saved login, keeping key, workspace and endpoint together. +The catalog carries `{ workspace, public, images }` and optional non-production `delivery`. +`TRANSLOADIT_WORKSPACE` overrides the catalog workspace when explicitly set in the app's environment. +Remove a stale override if image URLs point at another workspace; the factory does not read the CLI's +saved credentials file. Private `--write-env` creates +an owner-only `.env.local` containing only key and secret, never overwriting it. Omit that flag to +leave env files untouched. All keys are **server-only**, never `NEXT_PUBLIC_`. +Private initialization preserves already-published directories; it does not unpublish them. +The generated example selects a receipt in the initialized directory, or shows the empty state. +Public-only rendering reads workspace and policy from the catalog, not signing credentials. Private capability +prerenders need a build-time secret; request-only direct rendering can defer it to runtime. +Supply the same private credentials to the deployed route handler. + +CLI lookup is shell environment, current-directory `.env`, then the credentials file. +Ordinary commands retain this order. Storage commands print the selected credential source only +when a shell/project override wins, including mixed credentials and any declared workspace. +A declared env workspace is not proof of key ownership. Storage commands verify it through one +read for env/legacy keys or use the workspace verified during device login. A mismatch stops the +operation: `Project uses ; the selected credentials belong to . Nothing uploaded.` +`--workspace` explicitly selects another workspace but never mixes its records into the existing +catalog; use `--receipts` with a separate file. Login/init do not overwrite shell/project settings. +Login uses production unless `--endpoint` selects an explicit trusted API origin; this binding is +saved alongside the credential. Ordinary commands honor `TRANSLOADIT_ENDPOINT` under the same +lookup rules. Rendering never loads CLI credential files. The Assembly client is an upload-side +dependency, not part of rendering or the browser. + +`auth login --endpoint ` persists that endpoint in the saved login. On first catalog creation, +store records `delivery.baseUrl: '/file/{workspace}'` and `urlParams: { cdn: 'required' }` +for non-production. Subsequent writes preserve an existing delivery block. Remove it, and any +explicit plugin/factory overrides, to switch to production Smart CDN. Public rendering stays +secretless and production derives the CDN host from the catalog workspace. +For a separate login, set `TRANSLOADIT_CREDENTIALS_FILE` in your shell before logging in. +Console → Credentials contains the key; follow its real workspace link printed by the CLI. + +Init detects `app/` or `src/app/` and checks existing files before publishing. If a later local +write fails after publication, it reports that the prefix remains public. Do not unpublish shared +directories merely to retry a local scaffold. For manual setup, import the catalog into +`createStorageImages(catalog)`; `src/lib` imports the root catalog from +`../../transloadit.images.json`. See [local dogfood](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md) +for trusted devdock endpoint overrides and the required CDN acknowledgment. + +## Delivery overrides + +CLI `--endpoint` (saved by login) and `TRANSLOADIT_ENDPOINT` select the Assembly/Storage API. +A new non-production catalog records that origin in its delivery block; existing blocks are +preserved. The plugin accepts an explicit delivery override. The equivalent factory escape hatch is: + +```ts +import { createStorageImages } from '@transloadit/img/next/server' +import catalog from '../transloadit.images.json' + +export const { StorageImage } = createStorageImages({ + ...catalog, + baseUrl: 'https://api2-devdock.transloadit.dev/file/{workspace}', + urlParams: { cdn: 'required' }, +}) +``` + +This example is for a trusted local devdock, not production configuration. `baseUrl` is the +delivery base before the Template and image path, with an optional `{workspace}` placeholder; +it must be an absolute HTTP(S) URL without credentials, query string or fragment. Direct API2 +delivery requires the explicit `cdn: 'required'` acknowledgment. `urlParams` supplies transport +parameters; it cannot override image geometry, format, background, version or signing fields. +Never derive either option from browser input: a private delivery origin receives signed URLs. +Ordinary production delivery needs neither override and uses the workspace's Smart CDN hostname. + +## Redirect lifetime and caching + +Redirect capabilities hide filenames and bind one path and transformation. Authorization must +return exactly `true`. The handler responds with a fresh signed CDN URL in a `307`; no image bytes +pass through the app. By default each candidate load makes one app function invocation for +authorization and redirect — normally one per image per page view, more on candidate changes. +The matching responsive preload is reused by the image, not a second intended redirect. +Redact capabilities and signed CDN query strings from logs. A private capability still requires +application authorization; a downstream signed CDN URL is usable until its own expiry. +The capability has no independent expiry: current prefix and authorization policy is checked +again at the handler. Key/route/custom-Template or capability-contract changes can invalidate it. +Only cached redirects and already-issued CDN grants delay revocation. + +Default `Cache-Control: private, no-store` rechecks every redirect request. To trade faster repeat +loads for delayed reauthorization, opt in with `cacheMaxAge: '30s'`. The `307` uses +`private, max-age=30` (HTTP seconds), capped at the rotation interval and signed lifetime. Errors +remain `no-store`. Cached redirects may grant access without a new app check until that age elapses. +CDN URLs already issued remain usable until their own expiry; downloaded bytes cannot be recalled. + +After a directory is published, old private capabilities can redirect to its unsigned public URL. +These compatibility redirects share-cache for at most one minute: their request URL has no receipt +hash, so a longer cache could retain an old cache-tagged target after an overwrite and catalog refresh. +New public markup uses direct cache-tagged CDN URLs and does not take this compatibility route. + +### Cache and markup cost + +Production Smart CDN uses Bunny, configured on `*.tlcdn.com`: hostname and the whole query string +form the cache key. This is our pull-zone configuration, not universal Bunny behavior. +Format-specific URLs avoid unkeyed Accept negotiation. A representative constrained +hero has 11 image candidates (five AVIF, five WebP, one JPEG), plus five preload candidates. +The pinned Built-ins omit default JPEG format, quality 75, pad resizing and white background; +transparent formats retain their explicit background. Dimensions stay explicit. Custom Templates +keep all fields because their defaults are unknown; `cdn` is sent only when delivery configuration +sets it. This deliberately changes cache keys during unpublished dogfood. Markup overhead is not +transferred image bytes; compression and full-page RSC data vary. Private expiry/signature rotation +creates new cache entries (30 minutes by default). +Public URLs have no signature or expiry. They are cache-busted, not immutable origin identities: +an old uncached URL can fetch new bytes after a path overwrite. Prefer immutable filenames: +`storage store ./hero.jpg website/ --hashed` inserts the first eight hex digits of the input MD5 +before the extension, for example `website/hero.fce9d56a.jpg`. The catalog key, generated types and +printed JSX use that name; the receipt's `source` keeps the original local filename for humans. +The same bytes at the same destination are a no-op when the same-workspace catalog has a verified +receipt with matching full MD5, size and API origin. Hashed receipts record `apiOrigin` so a dev +workspace cannot stand in for production just because their slugs match. A missing or different +origin stops the command; use a separate `--receipts` catalog for that environment. +Commit the catalog: without that evidence the CLI cannot +prove a remote conflict is the same object. Restore the receipt or choose another basename; a +short-hash collision is never overwritten. Changed bytes get a new name, so `--overwrite` is not +needed and cannot be combined with `--hashed`. Do not modify the input while uploading. +The `v` tag is then belt-and-braces; hashed naming does not change the origin's versioning contract. +`v` is a cache-busting tag derived from the receipt hash; the origin does not verify it, so a cold +request after an overwrite can return the replacement. With a receipt MD5 it uses the first 16 hex digits and +responses use `public, max-age=31536000, s-maxage=31536000, immutable`. Changed bytes plus a refreshed +catalog change the cache key. Without an MD5, no cache tag is invented: the public Built-in uses its +ordinary three-day browser/one-day shared cache policy. Production Bunny cache hits/cost are a +separate deployment check, not something the local browser fixture establishes. + +### Direct delivery for request-authorized galleries + +Select `delivery: 'direct'` explicitly for request-rendered private delivery. It can reduce +application requests for galleries whose page data is already authorized: + +```ts +export const { StorageImage } = createStorageImages({ + allowedPathPrefixes: ['website/'], + delivery: 'direct', +}) +``` + +It avoids per-image application requests. Authorize the +page's image data before rendering. `connection()` defers signing to the request, with an inert, +source-free Suspense shell for partial prerendering. Do not cache the signed markup in a shared +full-page cache. A lazy candidate requested after expiry can fail; direct URLs are bearer grants +until expiry. Prefer redirects for long-lived pages. `suspenseFallback` replaces only the pending +server shell, not browser image failures. + +Without a catalog, prefixes or `allowWorkspaceRoot: true` are required. `[]` deliberately denies all. +Directory prefixes end in `/`; ambiguous paths are rejected. Prefixes bound signing but are not a +replacement for per-user object authorization. Rotate the secret together with cached markup: +existing redirect capabilities become invalid. + +### Template migrations and revocation + +Capabilities bind the payload-contract version, workspace and route/basePath, not the default +Built-in version. An SDK upgrade can pin a new compatible `storage-preview` without breaking old +private markup: the new handler signs with its current Built-in and rechecks current authorization. +There are no `previousTemplates` options or time-window chores for consumers. This unpublished +factory consolidation requires a one-time consumer update/rebuild; it is not a compatibility +promise for earlier experimental exports. + +An explicitly configured `template` is bound to the capability. Coordinate custom Template changes +with a cached-markup rebuild. Payload-contract changes also require a capability-version bump and +rebuild; ordinary Built-in updates do not. Rotating the signing secret invalidates existing +capabilities. Already-issued or cached CDN grants remain usable until their own expiry. +Public pinned Built-ins must remain served while their permanent URLs are in circulation; coordinate +backend migrations before retiring a version. The SDK never retires server Templates. +Opaque capabilities use server-side AES-GCM-SIV from `@noble/ciphers` for deterministic safe sealing. + +One factory owns both modes. Omitting `public`, `authorize` and `delivery: 'direct'` throws; +a catalog or prefix is not an authorization decision. `authorize` adds `storageRoute` to the result. + + +### Format, width and lifetime policy + +Private delivery pins `builtin/storage-preview@0.0.2`; public delivery pins +`builtin/public-preview@0.0.1`, which wraps `builtin/storage-preview@0.0.2`: the public URL pins +its transformation pipeline version too. AVIF quality 45 and WebP quality +75 precede a JPEG quality 75 fallback. Formats use separate URLs, not unkeyed Accept negotiation. +Candidate widths follow 320, 640, 960, 1280, 1920, 2560, 3840 plus intrinsic width, bounded by the +source and backend dimensions. `widths` overrides the ladder; the JPEG fallback is no larger than +its largest candidate. For a 48px avatar, `widths={[48, 96]}` also caps JPEG at 96px. + +Explicit `sizes` describes CSS layout; it does not set that layout. Without a derived or explicit +size, lazy images default to `sizes="auto, 100vw"` (automatic CSS-box sizing where supported, +viewport fallback otherwise); eager/preloaded images retain `100vw`. Auto sizing is lazy-only: +Chrome 126+ and [Firefox 150+](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/150) +support it; Safari does not yet. Browsers without support use the listed fallback. Keep explicit fallback lengths. +`objectFit` controls CSS, while the default `r: 'pad'` preserves source +proportions in encoded candidates. AVIF/WebP/PNG candidates use `bg: '#00000000'` to preserve +transparency through both preview and encoding; JPEG uses an opaque background, white by default. +`fallbackBackground="#224466"` changes only the JPEG background (six RGB hex digits, or eight RGBA +digits ending in `ff`). Named colors and transparent JPEG backgrounds are rejected before signing. +Pass raw hex colors: URL signing encodes `#` as `%23`. `bg` cannot be overridden through global +`urlParams`. A custom Template must support the same background field contract. +`formats` sets per-format quality; `fallbackQuality` sets JPEG quality. +`template` overrides only private previews; `publicTemplate` independently overrides public +delivery and must accept unsigned requests with the same fields. A private Built-in cannot serve +as a public override, even when its input directory is published. + +`lifetime` is a **private-grant maximum**, in milliseconds or a duration such as `'1h'`. +It defaults to one hour and cannot exceed 48 hours, including in mixed factories. Public URLs +ignore lifetime and rotation and never need an expiry-driven rebuild. +Private rotation defaults to half the lifetime, capped at one hour. The default grant therefore +has 30–60 minutes remaining, never 60–120. `rotationInterval` cannot exceed half the private +lifetime, preserving a delivery margin; smaller buckets reduce variation but fragment the cache. + +`cacheMaxAge` and `rotationInterval` accept milliseconds or the same strings as `lifetime`, for +example `'1m'` and `'30m'`. `cacheMaxAgeMs` and `rotationIntervalMs` are deprecated numeric aliases; +do not supply both spellings. In development, an image without explicit `sizes` can warn when +its decoded, density-corrected width exceeds twice its rendered CSS width. Transient 0/1px boxes +are ignored; cached/HiDPI resolution alone does not trigger the hint. Production does no size diagnostics. + +Without `errorFallback`, HTTP failure uses native broken-image/alt behavior. JPEG is a format +fallback, not HTTP-error recovery. + +### Experimental browser recovery controls + +`retryKey` is experimental, retained for the native-cookie sign-in recovery browser fixture. +Prefer the native behavior or `errorFallback` alone unless a +same-page sign-in/refresh needs an explicit retry identity. See [When it breaks](#when-it-breaks). + +### Receipt integrity and recovery + +Older deployments may watermark Community-plan uploads. The CLI reports changed bytes and saves +metadata for the actual stored image; it does not suggest overwriting that completed upload. + +The store command wraps `client.storeImage()`, waits for completion and validates `asset_id`, +the exact destination, stored byte count/MD5 and positive EXIF-oriented display dimensions. +The receipt lands in `results[':original']`, not `results.stored`. Older deployments can apply +Community-plan watermarks before Storage runs; newer API2 deployments preserve stored originals. +The receipt describes the stored bytes, not an assumption that they equal the local file. +The CLI warns about a changed size or checksum and saves that authoritative receipt normally. +`--log-level debug` adds the Assembly ID, bounded receipt summary and input comparison, never +raw Assembly responses or credentials. SDK callers can observe `(receipt, input, assemblyId)` +through the optional `onReceipt` callback. Observers are not awaited; synchronous exceptions and +asynchronous rejections do not discard completed writes. +Rendering requires no metadata lookup. + +`storage store ./images/*.jpg website/` accepts shell-expanded files and a directory destination. +Each successful upload is checkpointed before the next; a later failure preserves earlier receipts. +Duplicate destination basenames are refused before uploading unless `--hashed` distinguishes them +by content (identical bytes reuse the first receipt, even during an explicit workspace override). +The printed snippet uses a +filename-derived alt; replace it with an accurate description, or an empty alt for a decorative image. + +The CLI atomically appends to the catalog's `images` object keyed by Storage path, preserving earlier receipts +on failure. Parent directories must exist. A sibling lock prevents concurrent writers from losing +each other's records. Ctrl-C cancels active uploads and S3 reads, releases the lock, and checkpoints +any receipt that already returned before stopping. An accepted Assembly may still finish remotely: +check Storage or sync receipts before retrying a write. A forced exit or crash can leave a lock; +remove it only after confirming the writer has stopped. +New catalogs use ordinary file permissions derived from your umask; existing modes are preserved. +The credentials file remains private (`0600`). +Receipt validation occurs after the Storage write, not as a rollback. Do not re-upload or use +`--overwrite` to fix missing metadata. Existing paths conflict by default. +`storage store --overwrite` explicitly replaces an occupied path; it is never the default. Prefer +[hashed immutable filenames](#cache-and-markup-cost) because delivery resolves paths, not receipt +hashes, and cached bytes can outlive an overwrite. On older deployments that transform uploaded +bytes, the CLI still saves the authoritative receipt; a differing checksum makes a hashed replay +fail without uploading or replacing anything. Restoring that same transformed receipt cannot fix +the mismatch. Choose a fresh destination basename; hashed replay requires an origin that preserves +the uploaded bytes. The same refusal protects against a short-hash collision. + +### Recovery (requires the Storage read API, not yet enabled in production) + +If no usable receipt comes back, inspect with `storage ls` and recover with `storage receipts sync`, +using the same catalog. Until this API is enabled, restore the committed catalog or use the +[Assembly receipt recovery API](#images-uploaded-by-your-users) with trusted upload metadata. + +`storage ls` and `storage receipts sync` require the S3 read API, currently off in production until +`API2_STORAGE_S3_ENABLED` is deployed. HTTP 403 cannot distinguish a disabled API from denied access; +check the endpoint and key scope before retrying. + +`storage ls website/` lists the current workspace using its Auth Key with `read` or `dam:write` scope and the existing +S3-compatible read API, without an Assembly. `--workspace` overrides automatic workspace discovery. +S3 allows 30 seconds to receive headers and at most two attempts per request. A 60-second deadline +also covers retries and response-body reads; failed syncs release the catalog lock and leave the +existing catalog intact. +It uses the endpoint saved with those key credentials; `--endpoint` is an explicit trusted override +and accepts the API origin, not a bucket URL. The rendering factory's `baseUrl` is unrelated. +The Storage S3 API must be enabled separately: successful Assembly-based storage or image delivery +does not imply that listing is enabled. A disabled S3 API returns HTTP 403, even with valid credentials. + +Recover or refresh a rendering catalog without re-uploading or downloading originals: + +```console +npx transloadit storage receipts sync website/ +``` + +This uses paginated List + HEAD with the same `read` or `dam:write` credentials, `--workspace` and +`--endpoint` options as `storage ls`. Sync also reads `GET /storage/public_prefixes` with `dam:write` +scope and commits server-declared public policy and receipts atomically. If policy cannot be read, +recovery fails without changing the existing file. Folder names never imply public access. +An empty server policy is recovered as `public: []`, not silently republished. For intentionally +public images run `storage publish` on the intended directory; otherwise configure `authorize` +for private delivery. The CLI and factory explain this missing delivery choice. +HEAD's `x-amz-meta-dam-width` and `x-amz-meta-dam-height` +rebuild `{ path, width, height }`, which can be passed directly as `StorageImage`'s `src`. +`md5hash` is included only for compatible single-part ETags; multipart, opaque and SSE-KMS/SSE-C +ETags are not treated as MD5. See [S3's ETag contract](https://docs.aws.amazon.com/AmazonS3/latest/API/API_Object.html). +HEAD does not expose `asset_id`: sync recovers rendering metadata, not a verified upload receipt. +Sync preserves an existing `asset_id`, `size`, `source`, `apiOrigin`, `thumbhash` and `hasAlpha` only when the HEAD MD5 matches +the saved hash. A fresh sync has no original bytes and cannot reconstruct ThumbHash or alpha metadata. +It cannot generate a ThumbHash from List + HEAD; fresh recovered receipts leave that field absent. +Otherwise it replaces that entry with rendering metadata, so stale upload evidence is not retained. + +Sync adds or refreshes matching paths and never prunes unmatched entries. Any missing/invalid +dimensions, failed HEAD or incomplete listing leaves the existing file intact; a failed atomic +replacement retains the complete temporary catalog for recovery. Choose an image-only prefix; +older objects without dimensions need a catalog backfill. Storage records EXIF-oriented display +dimensions for new image uploads, so sync matches `storeImage` receipts for rotated photos too. +Commit `transloadit.images.json` before building so rendering needs no runtime metadata lookup; it can now +be regenerated from Storage rather than being the only copy of rendering metadata. + +### Images uploaded by your users + +The CLI is for repository/content seeding. In an application, use Uppy with its Transloadit plugin +or your existing Assembly upload flow, with a server-owned `/transloadit/store` step: + +```json +{ + "steps": { + "stored": { + "robot": "/transloadit/store", + "use": ":original", + "path": "uploads/server-generated-upload-id/${file.url_name}", + "conflict_strategy": "error" + } + } +} +``` + +The application server authenticates the uploader, chooses the destination prefix/upload ID, +and signs the Assembly parameters or a trusted Template. Transloadit interpolates the literal +`${file.url_name}`. Do not put the Assembly secret in Uppy/browser code or let a client choose +another user's destination/steps. Enable `uploads/` in the rendering factory's allowed prefixes +only alongside an exact per-object ownership check in `authorize`. + +Treat a notification as a wake-up signal. Correlate its Assembly ID with the upload your server +authorized, then use the write-side SDK client to fetch and verify the receipt: + +```ts +const receipt = await client.getStoredImageReceipt({ + assemblyId: upload.assemblyId, + expected: { path: upload.path, size: upload.size, md5hash: upload.md5hash }, +}) +await saveImage({ ...receipt, ownerId: upload.ownerId }) +``` + +Here `upload` is your trusted, server-side upload record, not an unchecked request body. The flow +is Uppy → store step → notification → `getStoredImageReceipt` → persist. The helper validates +one original, its exact path/asset_id and EXIF-oriented dimensions. Unlike the result of a write +initiated by `storeImage`, recovery from a separate Assembly ID also requires a size/MD5 match +with your trusted upload record. Community-plan watermarks can break that byte-identity check. +For transformed or multiple inputs, correlate and validate the appropriate annotated result step +yourself; do not replace trusted expectations with unchecked notification fields. +Failed Assemblies retain their `ApiError` code, such as `TRANSLOADIT_STORE_CONFLICT`. An unfinished +Assembly raises `InconsistentResponseError` naming its current status; retry recovery after it +finishes. A completed Assembly with mismatched receipt data remains an integrity error. + +The same call recovers a local receipts-file failure: get the completed Assembly ID from the CLI +error or Console, supply the original file's trusted path/size/MD5, and append the returned receipt +to your saved app data. Do not rerun the write merely to recover metadata. Persist the complete +receipt with your owner/project ID; never persist a browser-supplied receipt without verification. + +Dynamic receipts need an explicit factory: the Quickstart's package component infers its allowed +directories from the catalog and will reject `uploads/` when only `website/` was seeded. Keep that +public catalog unchanged. This separate private factory does not need the CLI catalog or a rebuild for each upload. +Use your workspace slug in place of `your-workspace` and the server-only application signing key +from [Private setup](#private); an uploads-only app needs no `withTransloaditImages` plugin. +The examples use `app/`; when using `src/app/`, put the factory in `src/app/` and helpers in `src/lib/`. + +```ts +// app/upload-images.ts +import { createStorageImages } from '@transloadit/img/next/server' +import { getSession } from '../lib/authorization' + +export const { StorageImage, storageRoute } = createStorageImages({ + workspace: 'your-workspace', + allowedPathPrefixes: ['uploads/'], + route: '/api/upload-images', + authorize: async ({ path, request }) => + (await getSession(request))?.canRead(path) === true, +}) +``` + +```ts +// app/api/upload-images/route.ts +export { storageRoute as GET, storageRoute as HEAD } from '../../upload-images' +``` + +`getSession`, `canRead` and `getAuthorizedImage` below are your application's helpers, not SDK helpers. +`canRead(path)` must check the current user's permission for that exact stored object, not merely +whether they are logged in or the path starts with `uploads/`. Keep `uploads/` private; do not publish +it or allow the workspace root. This route has its own path so it can coexist with the Quickstart route. + +Read the saved receipt in an authorized Server Component and import this factory's component, not +the catalog-bound package component. `getAuthorizedImage(id)` must authenticate the viewer, check +ownership and return the validated database receipt (or stop with a not-found/denied response): + +```tsx +// app/uploads/[id]/page.tsx +import type { ReactNode } from 'react' +import { getAuthorizedImage } from '../../../lib/images' +import { StorageImage } from '../../upload-images' + +interface PageProps { + params: Promise<{ id: string }> +} + +export default async function Page({ params }: PageProps): Promise { + const { id } = await params + const savedImage = await getAuthorizedImage(id) + return +} +``` + +`savedImage` is the application's validated database record; owner and asset IDs are never forwarded. +A public receipt's `v` is a cache-busting tag derived from the receipt hash; the origin does not +verify it, so a cold request after an overwrite can return the replacement. Private signing omits +the tag. Prefer immutable filenames; see [upload/overwrite guidance](#receipt-integrity-and-recovery). +The browser never needs the Assembly secret, Smart CDN secret, or a render-time metadata lookup. + +### Credentials and framework adapters + +`createStorageImages({ authKey, authSecret, workspace, images, public: ['website/'] })` supports +secret managers and multiple workspaces with the same flat options. The env factory snapshots only +the workspace and private signing pair on first use; it loads no files and uses the login key names +unless the complete Smart CDN override pair is supplied. The factory accepts a trusted compatible `template`, +`baseUrl` and transport `urlParams`. Never derive these signing policies from request input. + +`@transloadit/img` exposes `createTransloaditImageModel` and serializable model types for other +framework adapters. `@transloadit/img/next` renders a resolved model without owning credentials. + +“Native props” means serializable image attributes such as `alt`, `className`, `aria-*`, `data-*`, +`decoding` and `referrerPolicy`. Event callbacks and refs do not cross this Server Component +boundary. `src` and candidate URLs belong to the configured catalog and signing policy. diff --git a/packages/img/package.json b/packages/img/package.json index 8d2b31a7..345360c4 100644 --- a/packages/img/package.json +++ b/packages/img/package.json @@ -12,6 +12,7 @@ }, "files": [ "dist", + "docs", "README.md" ], "main": "./dist/index.js", @@ -23,8 +24,29 @@ }, "./next": { "types": "./dist/next/index.d.ts", + "react-server": "./dist/next/react-server.js", "default": "./dist/next/index.js" }, + "./next/authorize": { + "types": "./dist/next/authorize.d.ts", + "default": "./dist/next/authorize.js" + }, + "./next/catalog": { + "types": "./dist/next/catalog.d.ts", + "default": "./dist/next/catalog.js" + }, + "./next/config": { + "types": "./dist/next/config.d.ts", + "default": "./dist/next/config.js" + }, + "./next/options": { + "types": "./dist/next/options.d.ts", + "default": "./dist/next/options.js" + }, + "./next/route": { + "types": "./dist/next/route.d.ts", + "default": "./dist/next/route.js" + }, "./next/server": { "types": "./dist/next/server.d.ts", "default": "./dist/next/server.js" @@ -41,10 +63,11 @@ "dependencies": { "@noble/ciphers": "^1.3.0", "@transloadit/utils": "workspace:^", - "server-only": "^0.0.1" + "server-only": "^0.0.1", + "thumbhash": "0.1.1" }, "peerDependencies": { - "next": ">=16.0.0 <17.0.0", + "next": ">=16.3.3 <17.0.0", "react": ">=19.0.0 <20.0.0", "react-dom": ">=19.0.0 <20.0.0" }, @@ -63,7 +86,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "happy-dom": "^20.9.0", - "next": "16.3.0", + "next": "16.3.4", "react": "^19.2.6", "react-dom": "^19.2.6" } diff --git a/packages/img/src/imageBackground.ts b/packages/img/src/imageBackground.ts new file mode 100644 index 00000000..137eaf40 --- /dev/null +++ b/packages/img/src/imageBackground.ts @@ -0,0 +1,7 @@ +/** Fully transparent background supported by the Storage preview pipeline. */ +export const transparentImageBackground = '#00000000' + +/** JPEG requires an opaque six-digit RGB color or eight-digit RGBA color ending in ff. */ +export function isOpaqueImageBackground(value: unknown): value is string { + return typeof value === 'string' && value.trim() === value && /^#[0-9a-f]{6}(?:ff)?$/i.test(value) +} diff --git a/packages/img/src/imageSource.ts b/packages/img/src/imageSource.ts new file mode 100644 index 00000000..bf52208a --- /dev/null +++ b/packages/img/src/imageSource.ts @@ -0,0 +1,80 @@ +import { validateStoragePath } from '@transloadit/utils' + +/** Saved source geometry; structurally compatible with a verified SDK Storage receipt. */ +export interface TransloaditImageSource { + readonly path: string + readonly width: number + readonly height: number + /** Original-byte MD5 from a verified receipt or compatible Storage HEAD ETag. */ + readonly md5hash?: string + /** Optional base64 ThumbHash, generated from the original bytes by storage store. */ + readonly thumbhash?: string + /** An original alpha channel disables persistent blur backgrounds, without a client load handler. */ + readonly hasAlpha?: boolean +} + +/** A path needs separate dimensions; a receipt owns its dimensions. */ +export type TransloaditImageSourceProps = + | { src: string; width: number; height: number } + | { src: TransloaditImageSource; width?: never; height?: never } + +function validateDimension(value: unknown, name: string): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`) + } +} + +/** Copies and validates source geometry before attribute getters, suspension or signing. */ +export function snapshotImageSource(props: { + src: unknown + width?: unknown + height?: unknown +}): TransloaditImageSource { + const src = props.src + let path: unknown + let width: unknown + let height: unknown + let md5hash: unknown + let thumbhash: unknown + let hasAlpha: unknown + if (typeof src === 'string') { + path = src + width = props.width + height = props.height + } else { + if ( + typeof src !== 'object' || + src === null || + Array.isArray(src) || + !('path' in src) || + !('width' in src) || + !('height' in src) || + props.width !== undefined || + props.height !== undefined + ) { + throw new TypeError( + 'Storage image src must be one relative object path or a receipt without separate dimensions', + ) + } + path = src.path + width = src.width + height = src.height + md5hash = 'md5hash' in src ? src.md5hash : undefined + thumbhash = 'thumbhash' in src ? src.thumbhash : undefined + hasAlpha = 'hasAlpha' in src ? src.hasAlpha : undefined + } + if (typeof path !== 'string') throw new TypeError('Storage image receipt path must be a string') + validateStoragePath(path) + validateDimension(width, 'width') + validateDimension(height, 'height') + if (md5hash !== undefined && (typeof md5hash !== 'string' || !/^[a-f0-9]{32}$/i.test(md5hash))) + throw new TypeError('Storage image md5hash must be a 32-digit hexadecimal checksum') + return { + path, + width, + height, + ...(typeof md5hash === 'string' ? { md5hash: md5hash.toLowerCase() } : {}), + ...(typeof thumbhash === 'string' ? { thumbhash } : {}), + ...(hasAlpha === true ? { hasAlpha: true } : {}), + } +} diff --git a/packages/img/src/index.ts b/packages/img/src/index.ts index 86d78ad7..9207ef53 100644 --- a/packages/img/src/index.ts +++ b/packages/img/src/index.ts @@ -1,17 +1,24 @@ import type { SignSmartCdnImageRequest, SmartCdnImageFormat } from '@transloadit/utils' +import type { TransloaditImageSourceProps } from './imageSource.ts' + import { resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from '@transloadit/utils' -import { validateStoragePath } from './storagePath.ts' +import { isOpaqueImageBackground, transparentImageBackground } from './imageBackground.ts' +import { snapshotImageSource } from './imageSource.ts' export type { SignSmartCdnImageRequest, SmartCdnImageSignRequest } from '@transloadit/utils' +export type { TransloaditImageSource } from './imageSource.ts' + /** Signed Built-in used by default for Transloadit Storage previews. */ -export const transloaditStoragePreviewTemplate = 'builtin/storage-preview@0.0.1' +export const transloaditStoragePreviewTemplate = 'builtin/storage-preview@0.0.2' +/** Unsigned Built-in, served only under a server-declared public Storage prefix. */ +export const transloaditPublicStoragePreviewTemplate = 'builtin/public-preview@0.0.1' const defaultFallbackQuality = 75 const defaultResponsiveImageWidths: readonly number[] = [320, 640, 960, 1280, 1920, 2560, 3840] const minimumMillisecondTimestamp = 1_000_000_000_000 @@ -40,30 +47,37 @@ export interface TransloaditImageSourceSet { /** Serializable data consumed by framework renderers. */ export interface TransloaditImageModel { + /** Ordered viewport-specific crops; each includes its own JPEG fallback. */ + artDirection?: readonly { media: string; model: TransloaditImageModel }[] /** Fixed URL expiry. Omitted when an adapter resolves fresh URLs after browser authorization. */ expiresAt?: number fallbackUrl: string sources: readonly TransloaditImageSourceSet[] } -/** Framework-neutral options for a responsive Transloadit Storage preview. */ -export interface TransloaditImageModelOptions { - expiresAt: number +interface TransloaditImageModelConfiguration { + /** Optional output width/height ratio; requests a server-side fillcrop instead of padding. */ + cropAspectRatio?: number + expiresAt: Expiry + /** Opaque JPEG background as #rrggbb or #rrggbbff. Defaults to white. */ + fallbackBackground?: string + /** Optional JPEG width, capped by the resolved candidate ladder. */ + fallbackWidth?: number /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ fallbackQuality?: number formats?: StoragePreviewFormats - /** Storage preview aspect-ratio numerator. */ - height: number - /** Relative object path inside the configured Transloadit Storage workspace. */ - src: string - /** Trusted compatible signed Template. Defaults to `builtin/storage-preview@0.0.1`. */ + /** Maximum candidate width, additionally bounded by the source and backend dimensions. */ + maximumWidth?: number + /** Trusted compatible signed Template. Defaults to `builtin/storage-preview@0.0.2`. */ template?: string - /** Storage preview aspect-ratio denominator and conservative JPEG fallback width. */ - width: number /** Requested intrinsic candidate widths. Defaults to a conservative ladder up to the source. */ widths?: readonly number[] } +/** Framework-neutral options for a responsive Transloadit Storage preview. */ +export type TransloaditImageModelOptions = + TransloaditImageModelConfiguration & TransloaditImageSourceProps + function validateDimension(value: number, name: string): void { if (!Number.isInteger(value) || value < 1 || value > smartCdnImageMaxDimension) { throw new RangeError(`${name} must be an integer from 1 through ${smartCdnImageMaxDimension}`) @@ -82,9 +96,9 @@ function validateTemplate(template: string): void { } } -function validateQuality(quality: number, name: string): void { - if (!Number.isInteger(quality) || quality < 1 || quality > 100) { - throw new RangeError(`${name} must be an integer from 1 through 100`) +function validateQuality(quality: number, name: string, maximum = 100): void { + if (!Number.isInteger(quality) || quality < 1 || quality > maximum) { + throw new RangeError(`${name} must be an integer from 1 through ${maximum}`) } } @@ -103,52 +117,87 @@ function getResponsiveImageWidths( } /** Creates one signed, serializable responsive preview of a Transloadit Storage object. */ -export function createTransloaditImageModel( - options: TransloaditImageModelOptions, - sign: SignSmartCdnImageRequest, +export function createTransloaditImageModel( + options: TransloaditImageModelOptions, + sign: SignSmartCdnImageRequest, ): TransloaditImageModel { + const { path: src, width, height } = snapshotImageSource(options) const expiresAt = options.expiresAt + const cropAspectRatio = options.cropAspectRatio + const requestedMaximumWidth = options.maximumWidth + const requestedFallbackWidth = options.fallbackWidth + const fallbackBackground = options.fallbackBackground ?? '#ffffff' const fallbackQuality = options.fallbackQuality ?? defaultFallbackQuality const formats = options.formats === undefined ? undefined : { ...options.formats } - const height = options.height - const src = options.src const template = options.template ?? transloaditStoragePreviewTemplate - const width = options.width + // API2's exact public Built-in narrows the private preview's dimensions and quality. + // Customer templates and future Built-in versions retain their existing contract. + const maxDimension = + template === transloaditPublicStoragePreviewTemplate ? 4096 : smartCdnImageMaxDimension + const maxQuality = template === transloaditPublicStoragePreviewTemplate ? 85 : 100 const widthsSnapshot = Array.isArray(options.widths) ? [...options.widths] : options.widths - validatePositiveSafeInteger(expiresAt, 'expiresAt') - if (expiresAt < minimumMillisecondTimestamp) { - throw new RangeError('expiresAt must be a millisecond timestamp') + if (expiresAt !== undefined) { + validatePositiveSafeInteger(expiresAt, 'expiresAt') + if (expiresAt < minimumMillisecondTimestamp) + throw new RangeError('expiresAt must be a millisecond timestamp') } if (typeof sign !== 'function') throw new TypeError('sign must be a function') - validatePositiveSafeInteger(width, 'width') - validatePositiveSafeInteger(height, 'height') - validateQuality(fallbackQuality, 'fallbackQuality') - validateStoragePath(src) + validateQuality(fallbackQuality, 'fallbackQuality', maxQuality) + const resolvedFormats = resolveSmartCdnImageFormats(formats) + for (const { quality } of resolvedFormats) validateQuality(quality, 'quality', maxQuality) validateTemplate(template) - - const heightLimitedWidth = Number( - (BigInt(smartCdnImageMaxDimension) * BigInt(width)) / BigInt(height), - ) + if (!isOpaqueImageBackground(fallbackBackground)) { + throw new TypeError('fallbackBackground must be an opaque #rrggbb or #rrggbbff color') + } + if ( + cropAspectRatio !== undefined && + (!Number.isFinite(cropAspectRatio) || cropAspectRatio <= 0) + ) { + throw new RangeError('cropAspectRatio must be a positive finite number') + } + if (requestedMaximumWidth !== undefined) + validatePositiveSafeInteger(requestedMaximumWidth, 'maximumWidth') + if (requestedFallbackWidth !== undefined) + validatePositiveSafeInteger(requestedFallbackWidth, 'fallbackWidth') + + const ratioWidth = cropAspectRatio ?? width + const ratioHeight = cropAspectRatio === undefined ? height : 1 + const heightLimitedWidth = + cropAspectRatio === undefined + ? Number((BigInt(maxDimension) * BigInt(width)) / BigInt(height)) + : Math.floor(maxDimension * cropAspectRatio) if (heightLimitedWidth < 1) { throw new RangeError('display aspect ratio cannot fit within backend dimensions') } - const maximumWidth = Math.min(width, smartCdnImageMaxDimension, heightLimitedWidth) + const maximumWidth = Math.min( + width, + maxDimension, + heightLimitedWidth, + cropAspectRatio === undefined ? width : Math.floor(height * cropAspectRatio), + requestedMaximumWidth ?? width, + ) + if (maximumWidth < 1) { + throw new RangeError( + 'source dimensions and cropAspectRatio must allow a crop at least one pixel wide', + ) + } const widths = resolveSmartCdnImageWidths( getResponsiveImageWidths(widthsSnapshot, maximumWidth), maximumWidth, ) - const sources = resolveSmartCdnImageFormats(formats).map(({ format, quality }) => ({ + const sources = resolvedFormats.map(({ format, quality }) => ({ candidates: widths.map((candidateWidth) => ({ url: sign({ expiresAt, input: src, template, urlParams: { + bg: transparentImageBackground, f: format, - h: getStorageHeight(candidateWidth, width, height), + h: getStorageHeight(candidateWidth, ratioWidth, ratioHeight), q: quality, - r: 'pad', + r: cropAspectRatio === undefined ? 'pad' : 'fillcrop', w: candidateWidth, }, }), @@ -156,16 +205,17 @@ export function createTransloaditImageModel( })), format, })) - const fallbackWidth = Math.min(width, maximumWidth) + const fallbackWidth = Math.min(requestedFallbackWidth ?? width, Math.max(...widths)) const fallbackUrl = sign({ expiresAt, input: src, template, urlParams: { + bg: fallbackBackground, f: 'jpg', - h: getStorageHeight(fallbackWidth, width, height), + h: getStorageHeight(fallbackWidth, ratioWidth, ratioHeight), q: fallbackQuality, - r: 'pad', + r: cropAspectRatio === undefined ? 'pad' : 'fillcrop', w: fallbackWidth, }, }) diff --git a/packages/img/src/next/HydratedTransloaditPicture.tsx b/packages/img/src/next/HydratedTransloaditPicture.tsx deleted file mode 100644 index a80f4737..00000000 --- a/packages/img/src/next/HydratedTransloaditPicture.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' - -import { useSyncExternalStore } from 'react' - -interface HydratedTransloaditPictureProps { - children: ReactNode - fallback: ReactNode -} - -const subscribe = (): (() => void) => () => {} -const getClientSnapshot = (): true => true -const getServerSnapshot = (): false => false - -/** Mounts responsive source elements after hydration while retaining a no-script fallback. */ -export function HydratedTransloaditPicture({ - children, - fallback, -}: HydratedTransloaditPictureProps): ReactNode { - const hydrated = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot) - - return hydrated ? children : -} diff --git a/packages/img/src/next/ImageSizeDiagnostics.tsx b/packages/img/src/next/ImageSizeDiagnostics.tsx new file mode 100644 index 00000000..86cf78d3 --- /dev/null +++ b/packages/img/src/next/ImageSizeDiagnostics.tsx @@ -0,0 +1,70 @@ +'use client' + +import type { ReactNode } from 'react' + +import { useEffect, useRef } from 'react' + +interface ImageSizeDiagnosticsProps { + children: ReactNode +} + +/** Development-only native candidate inspection; the production renderer omits this boundary. */ +export function ImageSizeDiagnostics({ children }: ImageSizeDiagnosticsProps): ReactNode { + const ref = useRef(null) + useEffect(() => { + const container = ref.current + if (container === null) return + const warned = new WeakSet() + let frame = 0 + let observedImage: HTMLImageElement | undefined + const observer = new ResizeObserver(schedule) + function schedule(): void { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(inspect) + } + function inspect(): void { + const image = container?.querySelector('img') + if (!(image instanceof HTMLImageElement) || warned.has(image)) return + if (observedImage !== image) { + observer.disconnect() + observer.observe(image) + observedImage = image + } + if (!image.complete || image.naturalWidth === 0 || image.currentSrc === '') return + const { width: cssWidth, height } = image.getBoundingClientRect() + // Streamed/hydrating content can temporarily have a 1px box before its real layout. + if (cssWidth <= 1 || height <= 0) return + // naturalWidth is density-corrected CSS pixels. A larger cached/HiDPI candidate alone + // does not imply incorrect sizes when its intended display width matches the real box. + if (image.naturalWidth <= 2 * cssWidth) return + const sources = image.closest('picture')?.querySelectorAll('source') ?? [] + // A JPEG fallback has no width descriptor; its decoded natural width is unscaled. + let width = image.naturalWidth + for (const source of sources) { + for (const match of source.srcset.matchAll(/(?:^|, )(\S+) (\d+)w/g)) { + if (new URL(match[1], image.baseURI).href !== image.currentSrc) continue + width = Number(match[2]) + } + } + if (width <= 2 * cssWidth) return + warned.add(image) + console.warn( + `[StorageImage] The selected ${width}px candidate is more than twice its ${Math.round(cssWidth)}px rendered width. Set sizes to match the image’s CSS width.`, + ) + } + schedule() + container.addEventListener('load', schedule, true) + window.addEventListener('resize', schedule) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + container.removeEventListener('load', schedule, true) + window.removeEventListener('resize', schedule) + } + }, []) + return ( + + {children} + + ) +} diff --git a/packages/img/src/next/StorageImageErrorBoundary.tsx b/packages/img/src/next/StorageImageErrorBoundary.tsx new file mode 100644 index 00000000..c4cc4f80 --- /dev/null +++ b/packages/img/src/next/StorageImageErrorBoundary.tsx @@ -0,0 +1,41 @@ +'use client' + +import type { ComponentProps, ReactNode } from 'react' + +import { Children, cloneElement, isValidElement, useEffect, useRef, useState } from 'react' + +interface StorageImageErrorBoundaryProps { + children: ReactNode + fallback: ReactNode +} + +/** Retains the SSR picture; only an opted-in failed image switches to application-owned UI. */ +export function StorageImageErrorBoundary({ + children, + fallback, +}: StorageImageErrorBoundaryProps): ReactNode { + const picture = useRef(null) + const [failed, setFailed] = useState(false) + useEffect(() => { + const image = picture.current?.querySelector('img') + // The browser can finish (and fail) a native image request before hydration attaches events. + if (image?.complete && image.currentSrc !== '' && image.naturalWidth === 0) setFailed(true) + }, []) + if (failed) return fallback + // Flight can deliver children as a lazy reference, not a directly cloneable React element. + const elements = Children.toArray(children) + const element = elements[0] + if ( + elements.length !== 1 || + !isValidElement>(element) || + element.type !== 'picture' + ) { + throw new Error('Storage image error fallback requires one picture') + } + return cloneElement(element, { + ref: picture, + onErrorCapture(event) { + if (event.target instanceof HTMLImageElement) setFailed(true) + }, + }) +} diff --git a/packages/img/src/next/authorize.ts b/packages/img/src/next/authorize.ts new file mode 100644 index 00000000..1fd46a8c --- /dev/null +++ b/packages/img/src/next/authorize.ts @@ -0,0 +1,4 @@ +import type { AuthorizeTransloaditStorageImage } from './server.tsx' + +// Only the plugin may replace this with application code; request data never selects a module. +export const authorize: AuthorizeTransloaditStorageImage | undefined = undefined diff --git a/packages/img/src/next/catalog.ts b/packages/img/src/next/catalog.ts new file mode 100644 index 00000000..03379d74 --- /dev/null +++ b/packages/img/src/next/catalog.ts @@ -0,0 +1,18 @@ +import type { StorageImageCatalog } from './layout.ts' +import type { StorageImagesConfiguration } from './server.tsx' + +/** Nonsecret transport overrides for a trusted Storage endpoint. */ +export type StorageImageDelivery = Pick + +/** The CLI's committed project identity, public policy and original image geometry. */ +export interface StorageProjectCatalog { + workspace: string + public: readonly string[] + images: StorageImageCatalog + delivery?: StorageImageDelivery +} + +// The plugin replaces this module with the project's JSON. Keep import itself harmless so +// explicitly configured factories and model-only renderers do not require the plugin. +const catalog: StorageProjectCatalog | undefined = undefined +export default catalog diff --git a/packages/img/src/next/config.ts b/packages/img/src/next/config.ts new file mode 100644 index 00000000..01cfa979 --- /dev/null +++ b/packages/img/src/next/config.ts @@ -0,0 +1,110 @@ +import type { NextConfig } from 'next' + +import type { StorageImageDelivery } from './catalog.ts' + +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { isAbsolute, relative, resolve } from 'node:path' + +import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_SERVER } from 'next/constants.js' + +/** Bind a single project catalog; use explicit factories for several independently typed catalogs. */ +export interface TransloaditImagesOptions { + catalog?: string + delivery?: StorageImageDelivery + /** The Next.js app directory, for commands started from a monorepo's parent directory. */ + root?: string +} + +/** Bundle the catalog and optional authorizer with both Next bundlers; no runtime cwd lookup. */ +export function withTransloaditImages( + nextConfig: NextConfig = {}, + options: TransloaditImagesOptions = {}, +): (phase: string) => NextConfig { + // next start only serves compiled modules. Deployment may prune the generation cache and + // source catalog, or mount a read-only filesystem; neither is a runtime prerequisite. + return (phase) => + phase === PHASE_PRODUCTION_SERVER ? nextConfig : buildConfiguration(nextConfig, options, phase) +} + +function buildConfiguration( + nextConfig: NextConfig, + options: TransloaditImagesOptions, + phase: string, +): NextConfig { + const root = resolve(options.root ?? process.cwd()) + const catalog = resolve(root, options.catalog ?? 'transloadit.images.json') + function projectPath(file: string): string { + const path = relative(root, file).replaceAll('\\', '/') + if (path.startsWith('../') || isAbsolute(path)) + throw new Error( + 'The Storage image catalog must be inside the Next.js app; use an explicit factory for shared catalogs outside it.', + ) + return `./${path}` + } + const catalogPath = projectPath(catalog) + if (!statSync(catalog, { throwIfNoEntry: false })?.isFile()) + throw new Error( + `Missing Storage image catalog ${catalogPath}. Run transloadit storage store ./hero.jpg website/hero.jpg first (add --public only for public images), or select an existing catalog in withTransloaditImages.`, + ) + const authorize = resolve(root, 'transloadit.authorize.ts') + // Turbopack treats the build output directory as output, not an importable source tree. + const generated = resolve(root, 'node_modules/.cache/transloadit-images') + const configuration = resolve(generated, 'options.json') + const value = `${JSON.stringify({ + ...(phase === PHASE_DEVELOPMENT_SERVER + ? { authorizePath: authorize, diagnosticsId: catalog } + : {}), + ...(nextConfig.basePath ? { basePath: nextConfig.basePath } : {}), + ...(options.delivery === undefined + ? {} + : { + delivery: { + baseUrl: options.delivery.baseUrl, + urlParams: options.delivery.urlParams, + }, + }), + })}\n` + // Avoid needless invalidation in dev. This build-only JSON carries no catalog copy, + // authorization code or credentials. + if ( + !statSync(configuration, { throwIfNoEntry: false })?.isFile() || + readFileSync(configuration, 'utf8') !== value + ) { + mkdirSync(generated, { recursive: true }) + writeFileSync(configuration, value) + } + const aliases: Record = { + '@transloadit/img/next/catalog': catalogPath, + '@transloadit/img/next/options': projectPath(configuration), + ...(statSync(authorize, { throwIfNoEntry: false })?.isFile() + ? { '@transloadit/img/next/authorize': projectPath(authorize) } + : {}), + } + return { + ...nextConfig, + turbopack: { + ...nextConfig.turbopack, + resolveAlias: { ...nextConfig.turbopack?.resolveAlias, ...aliases }, + }, + outputFileTracingIncludes: { + ...nextConfig.outputFileTracingIncludes, + '/*': [ + ...new Set([ + ...(nextConfig.outputFileTracingIncludes?.['/*'] ?? []), + ...Object.values(aliases), + ]), + ], + }, + webpack(config, context) { + const configured = nextConfig.webpack?.(config, context) ?? config + configured.resolve ??= {} + configured.resolve.alias = { + ...configured.resolve.alias, + ...Object.fromEntries( + Object.entries(aliases).map(([name, path]) => [`${name}$`, resolve(root, path)]), + ), + } + return configured + }, + } +} diff --git a/packages/img/src/next/diagnostics.ts b/packages/img/src/next/diagnostics.ts new file mode 100644 index 00000000..8e2b1a04 --- /dev/null +++ b/packages/img/src/next/diagnostics.ts @@ -0,0 +1,105 @@ +import type { StorageProjectCatalog } from './catalog.ts' + +import { publishImageHint } from './pathHints.ts' + +/** Server-side development probe; logs the target origin/path, never queries or raw errors. */ +export type DiagnoseStorageImage = ( + path: string, + url: string, + publicPrefix?: string, +) => Promise + +const deliveryOverrideHint = + 'If you use a different API or CDN, set baseUrl/urlParams in the plugin delivery override or factory.' + +async function probe(path: string, url: string, publicPrefix?: string): Promise { + const target = new URL(url) + const safeUrl = `${target.origin}${target.pathname}` + try { + const response = await fetch(url, { + method: 'HEAD', + redirect: 'manual', + cache: 'no-store', + signal: AbortSignal.timeout(5000), + }) + const header = response.headers.get('Transloadit-Error') + // The header is an error-code label, never an arbitrary upstream message or response body. + const code = + header !== null && header.length <= 64 && /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(header) + ? header + : undefined + const status = `HTTP ${response.status}${code === undefined ? '' : ` (${code})`}` + const summary = `HEAD ${safeUrl}: ${status}` + if (response.ok && response.headers.get('content-type')?.startsWith('image/')) { + if ( + publicPrefix !== undefined && + response.headers.get('cache-control')?.includes('immutable') + ) + console.info( + `[StorageImage] Public delivery verified at ${safeUrl}: image response with immutable caching.`, + ) + return summary + } + // A manual HEAD cannot establish whether the browser's redirect target is a valid image. + if ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has('location')) + return summary + const hints = + code === 'INSUFFICIENT_AUTH_SCOPE' + ? 'Grant smart_cdn:sign for image delivery. In Console → Credentials, edit the application key: enable Smart CDN and smart_cdn:sign; assemblies:write is also accepted, but grants broader Assembly access.' + : response.status === 404 + ? 'Check the workspace slug, that the Storage path exists there, and the configured Template.' + : publicPrefix !== undefined && code === 'NO_SIGNATURE_FIELD' + ? `Storage path ${JSON.stringify(path)} may no longer be under a published public prefix. If already published, check its workspace and public Built-in. If it should be private, remove its public prefix from the catalog or factory and configure private delivery with application authorization. ${publishImageHint(path, publicPrefix)}` + : publicPrefix === undefined && (response.status === 401 || response.status === 403) + ? 'Enable Smart CDN on the Auth Key; check its workspace and the signature secret, expiry and server clock.' + : response.ok + ? 'Expected an image Content-Type. Check the configured Template and delivery endpoint.' + : `The delivery host did not serve this path as an image. Check the delivery endpoint and Template. ${deliveryOverrideHint}` + console.warn(`[StorageImage] Development HEAD ${safeUrl} returned ${status}. ${hints}`) + return summary + } catch { + // Error messages can include a credential-bearing URL. A HEAD failure does not establish + // whether the cause is credentials, networking, a cold transformation, or the CDN itself. + console.warn( + `[StorageImage] Could not reach Smart CDN at ${safeUrl} within five seconds. Check connectivity to this delivery host. ${deliveryOverrideHint}`, + ) + return `HEAD ${safeUrl}: could not reach the delivery host within five seconds` + } +} + +/** Deduplicates concurrent and repeated probes within one credentialed development integration. */ +export function createImageDiagnostics(template: string): DiagnoseStorageImage | undefined { + if (process.env.NODE_ENV !== 'development') return undefined + const requests = new Map>() + return (path, url, publicPrefix) => { + const key = JSON.stringify([path, template]) + const previous = requests.get(key) + if (previous !== undefined) return previous + const result = probe(path, url, publicPrefix) + requests.set(key, result) + return result + } +} + +declare global { + var __transloaditImagePublicPolicies: Map | undefined +} + +/** Keeps only dev policy snapshots across HMR; each project's catalog has a distinct identity. */ +export function diagnosePublicPolicy(catalog: StorageProjectCatalog, id?: string): void { + if (process.env.NODE_ENV !== 'development' || id === undefined) return + globalThis.__transloaditImagePublicPolicies ??= new Map() + const policies = globalThis.__transloaditImagePublicPolicies + const previous = policies.get(id) + policies.set(id, [...catalog.public]) + if (previous === undefined) return + const privatePaths = Object.keys(catalog.images).filter( + (path) => + previous.some((prefix) => path.startsWith(prefix)) && + !catalog.public.some((prefix) => path.startsWith(prefix)), + ) + if (privatePaths.length === 0) return + console.info( + `[StorageImage] Catalog public prefixes changed. These paths now require the private image route and authorization: ${privatePaths.map((path) => JSON.stringify(path)).join(', ')}.`, + ) +} diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts new file mode 100644 index 00000000..c2dbc133 --- /dev/null +++ b/packages/img/src/next/imageAttributes.ts @@ -0,0 +1,140 @@ +import type { DOMAttributes, ImgHTMLAttributes } from 'react' + +/** Native attributes that can cross the server-rendering boundary, without caller-owned URLs. */ +export interface ImageAttributes + extends Omit< + ImgHTMLAttributes, + | keyof DOMAttributes + | 'defaultChecked' + | 'defaultValue' + | 'inlist' + | 'loading' + | 'src' + | 'srcSet' + | 'suppressContentEditableWarning' + | 'suppressHydrationWarning' + | 'tw' + > { + // React types this RDFa attribute as any; only serializable values belong in this API. + inlist?: string + [attribute: `data-${string}`]: string | number | boolean | null | undefined +} + +// Exhaustive against React's native img attributes. New React attributes require an explicit +// decision here; arbitrary JS props must never leak factory configuration or override URLs. +const nativeAttributes: Record< + Exclude, + true +> = { + about: true, + accessKey: true, + alt: true, + autoCapitalize: true, + autoCorrect: true, + autoFocus: true, + autoSave: true, + className: true, + color: true, + content: true, + contentEditable: true, + contextMenu: true, + crossOrigin: true, + datatype: true, + decoding: true, + dir: true, + draggable: true, + enterKeyHint: true, + exportparts: true, + fetchPriority: true, + height: true, + hidden: true, + id: true, + inert: true, + inlist: true, + inputMode: true, + is: true, + itemID: true, + itemProp: true, + itemRef: true, + itemScope: true, + itemType: true, + lang: true, + nonce: true, + part: true, + popover: true, + popoverTarget: true, + popoverTargetAction: true, + prefix: true, + property: true, + radioGroup: true, + referrerPolicy: true, + rel: true, + resource: true, + results: true, + rev: true, + role: true, + security: true, + sizes: true, + slot: true, + spellCheck: true, + tabIndex: true, + title: true, + translate: true, + typeof: true, + unselectable: true, + useMap: true, + vocab: true, + width: true, +} + +/** Snapshots only native, serializable attributes before suspension or rendering. */ +export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes & { alt: string } { + const alt = props.alt + if (typeof alt !== 'string') throw new TypeError('Image alt must be a string') + const style = props.style + if (style != null && (typeof style !== 'object' || Array.isArray(style))) { + throw new TypeError('Image style must be an object') + } + const attributes = Object.fromEntries( + Object.entries(props).filter( + ([name, value]) => + (Object.hasOwn(nativeAttributes, name) || /^(?:aria|data)-[\w.-]+$/.test(name)) && + (value === undefined || + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean'), + ), + ) + return { ...attributes, alt, style: style == null ? undefined : { ...style } } +} + +/** A preload is eager; explicitly lazy images must not issue preload requests. */ +export type ImageLoadingProps = { + /** @deprecated Use preload; this alias remains for one release. */ + priority?: boolean +} & ( + | { loading?: 'eager'; preload?: boolean } + | { loading?: 'eager' | 'lazy'; preload?: false; priority?: false } +) + +/** Retains runtime validation for JavaScript callers as well as the discriminated public type. */ +export function snapshotImageLoading({ + loading, + priority, + preload, +}: { + loading?: 'eager' | 'lazy' + priority?: boolean + preload?: boolean +}): ImageLoadingProps { + if (priority !== undefined && process.env.NODE_ENV === 'development') + console.warn( + '[StorageImage] priority is deprecated; use preload. The alias will be removed after one release.', + ) + if (preload || priority) { + if (loading === 'lazy') throw new Error('A preloaded Transloadit image cannot use lazy loading') + return { loading: 'eager', preload: true } + } + return { loading, preload } +} diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx index bce31c63..a63a056a 100644 --- a/packages/img/src/next/index.tsx +++ b/packages/img/src/next/index.tsx @@ -5,42 +5,70 @@ import type { TransloaditImageModel, TransloaditImageSourceSet, } from '../index.ts' +import type { ImageAttributes, ImageLoadingProps } from './imageAttributes.ts' +import type { StorageImageCatalog } from './layout.ts' +import type { TransloaditRedirectImageProps } from './server.tsx' +import { Fragment } from 'react' import { preload as preloadResource } from 'react-dom' -import { HydratedTransloaditPicture } from './HydratedTransloaditPicture.tsx' +import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts' +import { StorageImageErrorBoundary } from './StorageImageErrorBoundary.tsx' + +/** Augmented by the CLI-generated transloadit-images.d.ts; an absent file keeps string sources. */ +// biome-ignore lint/suspicious/noEmptyInterface: This is the intentional consumer module-augmentation hook. +export interface RegisteredStorageImages {} + +/** The conventional catalog supplies intrinsic dimensions even without generated declarations. */ +export type StorageImageProps = TransloaditRedirectImageProps< + keyof RegisteredStorageImages extends never + ? StorageImageCatalog + : { + [Path in keyof RegisteredStorageImages]: RegisteredStorageImages[Path] extends StorageImageCatalog[string] + ? RegisteredStorageImages[Path] + : never + } +> + +/** Available to App Router Server Components through the react-server export condition. */ +export function StorageImage(_props: StorageImageProps): ReactNode { + throw new Error( + 'StorageImage is a Server Component. Render it in an App Router page or server component; use TransloaditPicture for an already resolved model in client code.', + ) +} -const transparentPixel = - 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' const mimeTypes = { avif: 'image/avif', png: 'image/png', webp: 'image/webp', } satisfies Record -/** Presentation options shared by the signed Server Component and model-only renderer. */ -export interface TransloaditImagePresentationProps { +interface ImagePresentationProps extends Omit { alt: string - className?: string - deferUntilHydrated?: boolean - fetchPriority?: 'auto' | 'high' | 'low' - height: number - loading?: 'eager' | 'lazy' - media?: string - /** CSP-compatible placeholder used while `media` is unmatched. Defaults to an inline GIF. */ - mediaPlaceholderSrc?: string + /** Optional client-side image-load fallback. Does not replace the server-rendered picture. */ + errorFallback?: ReactNode + /** @experimental Change after signing in to reset a failed image with stable URLs. */ + retryKey?: string | number /** Explicitly handles a display box whose aspect ratio differs from the source image. */ objectFit?: CSSProperties['objectFit'] - preload?: boolean /** Expected rendered widths. Browsers otherwise assume `100vw` for width-based source sets. */ sizes?: string - style?: CSSProperties +} + +/** Layout and loading without assuming how the caller supplies source dimensions. */ +export type TransloaditImageLayoutProps = ImagePresentationProps & ImageLoadingProps + +/** Serializable native image attributes and layout shared by both Next.js renderers. */ +export type TransloaditImagePresentationProps = TransloaditImageLayoutProps & { + height: number width: number } /** Props for rendering an already-signed framework-neutral image model. */ -export interface TransloaditPictureProps extends TransloaditImagePresentationProps { +export type TransloaditPictureProps = TransloaditImagePresentationProps & { model: TransloaditImageModel + /** Already decoded on the server; no ThumbHash decoder enters the client graph. */ + blurDataURL?: string } function getSourceSet(candidates: readonly TransloaditImageCandidate[]): string { @@ -54,6 +82,17 @@ function getMimeType(format: TransloaditImageSourceSet['format']): string { return mimeTypes[format] } +function getImageRecoveryKey({ model, retryKey }: TransloaditPictureProps): string { + const identity = JSON.stringify([model.fallbackUrl, model.sources, model.artDirection, retryKey]) + // FNV-1a is only a remount identity, never an authorization hash. Keep all candidate URLs out + // of the Flight key without requiring Node crypto or asynchronous rendering in this component. + let hash = 0xcbf29ce484222325n + for (const byte of new TextEncoder().encode(identity)) { + hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n) + } + return hash.toString(16).padStart(16, '0') +} + function escapeSourceSetUrl(url: string): string { const sourceSet = url .replaceAll('\t', '%09') @@ -75,8 +114,8 @@ function escapeSourceSetUrl(url: string): string { function preloadImage( source: TransloaditImageSourceSet, - sizes: string | undefined, - fetchPriority?: 'auto' | 'high' | 'low', + sizes: string, + { crossOrigin, fetchPriority, referrerPolicy }: ImageAttributes, ): void { const firstCandidate = source.candidates[0] if (firstCandidate === undefined) { @@ -85,132 +124,164 @@ function preloadImage( preloadResource(firstCandidate.url, { as: 'image', + crossOrigin, fetchPriority, imageSizes: sizes, imageSrcSet: getSourceSet(source.candidates), + referrerPolicy, type: getMimeType(source.format), }) } -function OriginalImage({ - alt, - className, - fetchPriority, - height, - loading, - objectFit, - src, - style, - width, -}: Pick< - TransloaditImagePresentationProps, - 'alt' | 'className' | 'fetchPriority' | 'height' | 'loading' | 'objectFit' | 'style' | 'width' -> & { - src?: string -}): ReactNode { - return ( - // biome-ignore lint/performance/noImgElement: This package is the image optimizer. - {alt} - ) -} - -/** - * Renders browser-selected responsive candidates with one fallback. `media` keeps an unmatched - * viewport inert; the caller controls whether its layout still reserves space in that viewport. - * `deferUntilHydrated` avoids WebKit parser-to-hydration request replay. - */ -export function TransloaditPicture({ - alt, - className, - deferUntilHydrated = false, - fetchPriority, - height, - loading, - media, - mediaPlaceholderSrc, - model, - objectFit, - preload = false, - sizes, - style, - width, -}: TransloaditPictureProps): ReactNode { - if (deferUntilHydrated && (loading === 'eager' || preload)) { - throw new Error('An eager or preloaded Transloadit image cannot be deferred until hydration') - } - if (preload && loading === 'lazy') { - throw new Error('A preloaded Transloadit image cannot use lazy loading') - } - if (preload && media !== undefined) { - // React 19's responsive-preload identity omits media and can silently collapse art direction. - throw new Error('A media-gated Transloadit image cannot be preloaded') - } +/** Renders immediately discoverable, browser-selected candidates with a JPEG fallback. */ +export function TransloaditPicture(props: TransloaditPictureProps): ReactNode { + const { model, objectFit, sizes: explicitSizes } = props + const { loading, preload = false } = snapshotImageLoading(props) const resolvedLoading = loading ?? (preload ? 'eager' : 'lazy') + let sizes = explicitSizes ?? (resolvedLoading === 'lazy' ? 'auto, 100vw' : '100vw') + let automaticSizes = /^auto(?:\s*,|\s*$)/i.test(sizes.trimStart()) + if (automaticSizes && resolvedLoading !== 'lazy') { + if (process.env.NODE_ENV === 'development') + console.warn( + '[StorageImage] auto sizes require lazy loading; using the explicit fallback for this eager image.', + ) + sizes = + sizes + .trimStart() + .replace(/^auto(?:\s*,\s*|\s*$)/i, '') + .trim() || '100vw' + automaticSizes = false + } if (model.sources.length === 0) { throw new Error('Cannot render a Transloadit image without a source') } + const attributes = snapshotImageAttributes(props) + if (preload) attributes.fetchPriority = 'high' + let blurStyle: CSSProperties | undefined + if (props.blurDataURL !== undefined) { + if ( + typeof props.blurDataURL !== 'string' || + props.blurDataURL.length > 6000 || + !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(props.blurDataURL) + ) + throw new TypeError('blurDataURL must be a bounded base64 PNG data URL') + const fit = objectFit ?? attributes.style?.objectFit ?? 'fill' + // ThumbHash only approximates the source ratio. A retained background must never extend + // into letterboxing beside loaded pixels; box-filling images cover it without client JS. + if (fit === 'cover' || fit === 'fill') { + blurStyle = { + backgroundImage: `url("${props.blurDataURL}")`, + backgroundPosition: attributes.style?.objectPosition ?? 'center', + backgroundRepeat: 'no-repeat', + backgroundSize: '100% 100%', + } + } else if (process.env.NODE_ENV === 'development') { + console.warn( + '[StorageImage] letterboxed image: no blur placeholder. Use the default constrained layout or fit="cover" for a box-filling image.', + ) + } + } + const artDirection = model.artDirection ?? [] const original = ( - takes precedence over either fallback. - src={media ? (mediaPlaceholderSrc ?? transparentPixel) : model.fallbackUrl} - style={style} - width={width} + // Without img srcset, only lazy auto sizing is valid here. Fallback lengths stay on source. + sizes={automaticSizes ? 'auto' : undefined} + src={model.fallbackUrl} + style={{ + ...attributes.style, + ...(objectFit === undefined ? {} : { objectFit }), + ...blurStyle, + }} /> ) - const fallback = media ? ( - - - {original} - - ) : ( - original - ) - if (preload) { + if (preload && artDirection.length === 0) { const preferredSource = model.sources[0] if (preferredSource === undefined) { throw new Error('Cannot preload a Transloadit image without a source') } - preloadImage(preferredSource, sizes, fetchPriority) + preloadImage(preferredSource, sizes, attributes) } + const preloads = + preload && artDirection.length > 0 + ? [...artDirection, { media: undefined, model }].map((variant, index) => { + const preferred = variant.model.sources[0] + if (preferred === undefined) throw new Error('Cannot preload an empty art direction') + const prior = artDirection.slice(0, index).map((source) => source.media) + const unmatched = prior.length === 0 ? undefined : `not (${prior.join(' or ')})` + const condition = + variant.media === undefined + ? unmatched + : unmatched === undefined + ? variant.media + : `${variant.media} and (${unmatched})` + return ( + + ) + }) + : null const picture = ( + {artDirection.map((variant) => ( + + {variant.model.sources.map((source) => ( + + ))} + + + ))} {model.sources.map((source) => ( ))} - {media ? : null} {original} ) - return deferUntilHydrated ? ( - {picture} - ) : ( - picture + const resolved = + props.errorFallback === undefined ? ( + picture + ) : ( + + {picture} + + ) + return ( + <> + {preloads} + {resolved} + ) } diff --git a/packages/img/src/next/layout.ts b/packages/img/src/next/layout.ts new file mode 100644 index 00000000..f86d5f73 --- /dev/null +++ b/packages/img/src/next/layout.ts @@ -0,0 +1,202 @@ +import type { CSSProperties } from 'react' + +import type { TransloaditImageSource } from '../imageSource.ts' + +import { snapshotImageSource } from '../imageSource.ts' +import { missingImageHint } from './pathHints.ts' + +/** Committed rendering receipts indexed by their exact Storage paths. */ +export type StorageImageCatalog = Readonly> + +type CatalogSource = TransloaditImageSource | Extract + +type PresentationSourceProps = + | (Catalog extends undefined ? { src: string; width: number; height: number } : never) + | { src: CatalogSource; width?: number; height?: number } + +/** Crop ratios selected by viewport width; default is required for all other viewports. */ +export type StorageImageAspectRatio = + | string + | number + | Readonly<{ default: string | number } & Record> + +/** Receipt sources are responsive by default; none leaves presentation sizing to the caller. */ +export type StorageImageLayoutProps = + | (PresentationSourceProps & { + layout?: 'constrained' | 'none' + fit?: never + aspectRatio?: never + frame?: never + }) + | { + layout: 'fixed' + src: CatalogSource + width: number + height: number + fit?: 'contain' | 'cover' + aspectRatio?: never + frame?: never + } + | ({ + layout: 'fill' + src: CatalogSource + width?: never + height?: never + /** Opt out when the app already owns the positioned box and its responsive ratios. */ + frame?: false + } & ( + | { fit: 'cover'; aspectRatio: StorageImageAspectRatio } + | { fit?: 'contain'; aspectRatio?: string | number } + )) + +interface ResolvedImageLayout { + source: TransloaditImageSource + width: number + height: number + cropAspectRatio?: number + artDirection?: readonly { media: string; cropAspectRatio: number }[] + fallbackWidth?: number + maximumWidth?: number + sizes?: string + style?: CSSProperties + widths?: readonly number[] + frame?: { ratio: number; variants: readonly { media: string; cropAspectRatio: number }[] } +} + +function boxDimension(value: number | undefined, name: string): number { + if (value === undefined || !Number.isSafeInteger(value) || value < 1 || value > 8000) { + throw new RangeError(`${name} must be an integer from 1 through 8000`) + } + return value +} + +function parseAspectRatio(value: string | number | undefined): number { + const pieces = typeof value === 'string' ? value.split('/').map(Number) : [value] + const [width, height = 1] = pieces + if ( + pieces.length > 2 || + width === undefined || + !Number.isFinite(width) || + width <= 0 || + !Number.isFinite(height) || + height <= 0 || + !Number.isFinite(width / height) + ) { + throw new TypeError('Cover fill requires a positive aspectRatio, for example "9/16"') + } + return width / height +} + +/** Snapshots intrinsic and box geometry before any caller-owned attribute getter can mutate it. */ +export function resolveImageLayout( + props: StorageImageLayoutProps & { widths?: readonly number[] }, + images?: StorageImageCatalog, +): ResolvedImageLayout { + const input = props.src + const src = + typeof input === 'string' && images !== undefined + ? Object.hasOwn(images, input) + ? images[input] + : undefined + : input + if (src === undefined) + throw new TypeError( + typeof input === 'string' + ? missingImageHint(input, Object.keys(images ?? {})) + : 'Storage image src is required', + ) + const layout = props.layout ?? (typeof src === 'string' ? 'none' : 'constrained') + if ((layout === 'fixed' || layout === 'fill') && typeof src === 'string') { + throw new TypeError( + `${layout} layout requires a receipt source with intrinsic dimensions${layout === 'fixed' ? '; width and height describe the display box' : ''}`, + ) + } + const source = snapshotImageSource( + typeof src === 'string' ? { src, width: props.width, height: props.height } : { src }, + ) + const presentationWidth = typeof src === 'string' ? undefined : props.width + const presentationHeight = typeof src === 'string' ? undefined : props.height + const width = + presentationWidth === undefined + ? presentationHeight === undefined + ? source.width + : Math.max(1, Math.round((presentationHeight * source.width) / source.height)) + : boxDimension(presentationWidth, 'width') + const height = + presentationHeight === undefined + ? presentationWidth === undefined + ? source.height + : Math.max(1, Math.round((presentationWidth * source.height) / source.width)) + : boxDimension(presentationHeight, 'height') + const widths = Array.isArray(props.widths) ? [...props.widths] : props.widths + const base = { source, width, height, widths } + if (layout === 'none') return base + if (layout === 'constrained') { + // Explicit display dimensions were validated above; an original may exceed the CDN output cap. + const maxWidth = Math.min(width, source.width) + return { + ...base, + width: maxWidth, + height: Math.max(1, Math.round((maxWidth * source.height) / source.width)), + maximumWidth: widths === undefined ? 2 * maxWidth : undefined, + sizes: `(min-width: ${maxWidth}px) ${maxWidth}px, 100vw`, + style: { display: 'block', maxWidth, width: '100%', height: 'auto' }, + } + } + const fit = props.fit ?? 'contain' + if (fit !== 'contain' && fit !== 'cover') throw new TypeError('fit must be contain or cover') + if (layout === 'fixed') { + const width = boxDimension(presentationWidth, 'width') + const height = boxDimension(presentationHeight, 'height') + return { + ...base, + width, + height, + cropAspectRatio: fit === 'cover' ? width / height : undefined, + fallbackWidth: width, + sizes: `${width}px`, + style: { display: 'block', height, width, objectFit: fit }, + widths: widths ?? [width, Math.min(8000, 2 * width)], + } + } + if (layout === 'fill') { + const aspectRatio = props.aspectRatio + const breakpoints = + typeof aspectRatio === 'object' && aspectRatio !== null ? aspectRatio : undefined + if ( + breakpoints !== undefined && + (fit !== 'cover' || Array.isArray(breakpoints) || Object.keys(breakpoints).length > 9) + ) { + throw new TypeError( + 'Art direction requires fill cover with default and up to eight width breakpoints', + ) + } + const artDirection = + breakpoints === undefined + ? undefined + : Object.entries(breakpoints) + .filter(([media]) => media !== 'default') + .map(([media, ratio]) => { + if (!/^\((?:min|max)-width:\s*\d+(?:\.\d+)?(?:px|em|rem)\)$/.test(media)) + throw new TypeError( + 'Art direction keys must be width breakpoints, for example (max-width: 639px)', + ) + return { media, cropAspectRatio: parseAspectRatio(ratio) } + }) + const ratio = + aspectRatio === undefined && fit !== 'cover' + ? undefined + : parseAspectRatio(typeof aspectRatio === 'object' ? breakpoints?.default : aspectRatio) + return { + ...base, + cropAspectRatio: fit === 'cover' ? ratio : undefined, + artDirection, + frame: + props.frame === false || ratio === undefined + ? undefined + : { ratio, variants: artDirection ?? [] }, + style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: fit }, + } + } + throw new TypeError('layout must be constrained, fixed, fill or none') +} diff --git a/packages/img/src/next/options.ts b/packages/img/src/next/options.ts new file mode 100644 index 00000000..847b256e --- /dev/null +++ b/packages/img/src/next/options.ts @@ -0,0 +1,14 @@ +import type { StorageImageDelivery } from './catalog.ts' + +/** Build-time overrides; application keys always stay in the server environment. */ +export interface StorageImageProjectOptions { + /** Development-only path for explaining an authorizer added after config evaluation. */ + authorizePath?: string + basePath?: string + delivery?: StorageImageDelivery + /** Development-only identity for deduplicated policy-change notices across hot reloads. */ + diagnosticsId?: string +} + +const options: StorageImageProjectOptions = {} +export default options diff --git a/packages/img/src/next/pathHints.ts b/packages/img/src/next/pathHints.ts new file mode 100644 index 00000000..df00283d --- /dev/null +++ b/packages/img/src/next/pathHints.ts @@ -0,0 +1,61 @@ +import { validateStoragePath } from '@transloadit/utils' + +function quoteArgument(value: string): string { + // Copyable POSIX commands must not expand a path containing quotes or shell expressions. + return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'` +} + +function editDistance(left: string, right: string, limit: number): number { + if (Math.abs(left.length - right.length) > limit) return limit + 1 + let row = Array.from({ length: right.length + 1 }, (_, index) => index) + for (let i = 1; i <= left.length; i++) { + const next = [i] + for (let j = 1; j <= right.length; j++) { + next[j] = Math.min( + next[j - 1] + 1, + row[j] + 1, + row[j - 1] + Number(left[i - 1] !== right[j - 1]), + ) + } + if (Math.min(...next) > limit) return limit + 1 + row = next + } + return row[right.length] +} + +/** Actionable catalog errors; suggestions are bounded to short paths and small spelling errors. */ +export function missingImageHint(path: string, paths: readonly string[]): string { + // Shell quoting cannot neutralize terminal control characters or bound an oversized log line. + try { + validateStoragePath(path) + } catch (error) { + // A formatting mistake can name an exact existing key without becoming upload advice. + const corrected = path.length <= 256 ? path.trim().replace(/^\/+/, '') : undefined + if (corrected !== undefined && paths.includes(corrected)) + return `Storage image path ${JSON.stringify(path)} is invalid. Did you mean ${JSON.stringify(corrected)}? Use the exact catalog key; no upload is needed.` + throw error + } + let nearest: string | undefined + let distance = 4 + if (path.length <= 256) { + for (const candidate of paths) { + if (candidate.length > 256) continue + const score = editDistance(path, candidate, distance - 1) + if (score >= distance) continue + nearest = candidate + distance = score + } + } + const suggestion = nearest === undefined ? '' : ` Did you mean ${JSON.stringify(nearest)}?` + return `Storage image path ${JSON.stringify(path)} is not in the configured catalog.${suggestion} For a custom catalog, add --receipts to the command. For an explicit factory, update its images configuration too. To upload a new image, run:\n npx transloadit storage store -- ./image.jpg ${quoteArgument(path)}` +} + +/** Publication is an explicit choice, never an automatic remedy for a denied private image. */ +export function publishImageHint( + path: string, + prefix = path.slice(0, path.lastIndexOf('/') + 1), +): string { + if (prefix === '') + return 'If it should be public, store it in a directory and publish that prefix; workspace-root publication is not supported.' + return `For a custom catalog, add --receipts to the command. Keep an explicit factory’s public list in sync too. If it should be public, run:\n npx transloadit storage publish -- ${quoteArgument(prefix)}` +} diff --git a/packages/img/src/next/project.ts b/packages/img/src/next/project.ts new file mode 100644 index 00000000..2c6fa813 --- /dev/null +++ b/packages/img/src/next/project.ts @@ -0,0 +1,36 @@ +import 'server-only' + +import type { StorageImageCatalog } from './layout.ts' +import type { TransloaditImageIntegration, TransloaditRedirectImageIntegration } from './server.tsx' + +import { authorize } from '@transloadit/img/next/authorize' +import catalog from '@transloadit/img/next/catalog' +import options from '@transloadit/img/next/options' + +import { diagnosePublicPolicy } from './diagnostics.ts' +import { createStorageImages } from './server.tsx' + +type ProjectIntegration = + | TransloaditImageIntegration + | TransloaditRedirectImageIntegration +let integration: ProjectIntegration | undefined + +/** One integration per bundled project; credentials retain the factory's lazy server-only lookup. */ +export function getProjectImages(): ProjectIntegration { + if (catalog === undefined) + throw new Error( + 'Add withTransloaditImages() from @transloadit/img/next/config to next.config.ts, or use createStorageImages with an explicit catalog.', + ) + if (integration !== undefined) return integration + diagnosePublicPolicy(catalog, options.diagnosticsId) + integration = createStorageImages({ + ...catalog, + ...catalog.delivery, + ...options.delivery, + // The catalog's delivery object is transport, not the factory's private direct-delivery mode. + delivery: undefined, + authorize, + ...(authorize === undefined ? {} : { basePath: options.basePath }), + }) + return integration +} diff --git a/packages/img/src/next/react-server.tsx b/packages/img/src/next/react-server.tsx new file mode 100644 index 00000000..362b55f6 --- /dev/null +++ b/packages/img/src/next/react-server.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' + +import type { StorageImageProps } from './index.tsx' + +import { statSync } from 'node:fs' + +import options from '@transloadit/img/next/options' + +import { getProjectImages } from './project.ts' + +export type { + RegisteredStorageImages, + StorageImageProps, + TransloaditImageLayoutProps, + TransloaditImagePresentationProps, + TransloaditPictureProps, +} from './index.tsx' + +export { TransloaditPicture } from './index.tsx' + +/** Render a project catalog image directly from Smart CDN, or through an authorized redirect. */ +export function StorageImage(props: StorageImageProps): ReactNode { + try { + return getProjectImages().StorageImage(props) + } catch (error) { + // Config discovers aliases once. Diagnose a late file, but never load or trust it at runtime. + if ( + process.env.NODE_ENV === 'development' && + error instanceof TypeError && + error.message === "Private images require authorize or delivery: 'direct'" && + options.authorizePath !== undefined && + statSync(options.authorizePath, { throwIfNoEntry: false })?.isFile() + ) + throw new TypeError( + 'transloadit.authorize.ts exists but was added after next dev started. Restart next dev to bundle it.', + { cause: error }, + ) + throw error + } +} diff --git a/packages/img/src/next/route.ts b/packages/img/src/next/route.ts new file mode 100644 index 00000000..6a881773 --- /dev/null +++ b/packages/img/src/next/route.ts @@ -0,0 +1,13 @@ +import 'server-only' + +import { getProjectImages } from './project.ts' + +/** Re-export at app/api/storage-images/route.ts; private requests always consult authorize. */ +export async function GET(request: Request): Promise { + const integration = getProjectImages() + if (!('storageRoute' in integration) || typeof integration.storageRoute !== 'function') + return new Response(null, { status: 404, headers: { 'Cache-Control': 'no-store' } }) + return await integration.storageRoute(request) +} + +export { GET as HEAD } diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx index 35b27329..092454be 100644 --- a/packages/img/src/next/server.tsx +++ b/packages/img/src/next/server.tsx @@ -8,22 +8,35 @@ import type { StoragePreviewFormats, TransloaditImageModel, } from '../index.ts' -import type { TransloaditImagePresentationProps } from './index.tsx' +import type { DiagnoseStorageImage } from './diagnostics.ts' +import type { TransloaditImageLayoutProps, TransloaditImagePresentationProps } from './index.tsx' +import type { StorageImageCatalog, StorageImageLayoutProps } from './layout.ts' -import { hkdfSync } from 'node:crypto' +import { createHash, hkdfSync } from 'node:crypto' import { gcmsiv } from '@noble/ciphers/aes.js' -import { getSignedSmartCdnUrl } from '@transloadit/utils/node' +import { validateStoragePath, validateStoragePathPrefix } from '@transloadit/utils' +import { getSignedSmartCdnUrl, getSmartCdnUrl } from '@transloadit/utils/node' import { connection } from 'next/server.js' -import { Suspense } from 'react' +import { Suspense, use } from 'react' +import { thumbHashToDataURL } from 'thumbhash' -import { createTransloaditImageModel, transloaditStoragePreviewTemplate } from '../index.ts' -import { validateStoragePath, validateStoragePathPrefix } from '../storagePath.ts' +import { isOpaqueImageBackground, transparentImageBackground } from '../imageBackground.ts' +import { snapshotImageSource } from '../imageSource.ts' +import { + createTransloaditImageModel, + transloaditPublicStoragePreviewTemplate, + transloaditStoragePreviewTemplate, +} from '../index.ts' +import { createImageDiagnostics } from './diagnostics.ts' +import { ImageSizeDiagnostics } from './ImageSizeDiagnostics.tsx' +import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts' import { TransloaditPicture } from './index.tsx' +import { resolveImageLayout } from './layout.ts' +import { publishImageHint } from './pathHints.ts' const defaultStorageExpiresInMs = 60 * 60 * 1000 -const defaultStorageRotationIntervalMs = 5 * 60 * 1000 -const imagePolicyParams = new Set(['auth_key', 'exp', 'f', 'h', 'q', 'r', 'sig', 'w']) +const imagePolicyParams = new Set(['auth_key', 'bg', 'exp', 'f', 'h', 'q', 'r', 'sig', 'v', 'w']) const maximumImageDimension = 8000 const maximumStorageLifetimeMs = 48 * 60 * 60 * 1000 const storageCapabilityAuthenticationBytes = 16 @@ -39,7 +52,7 @@ export interface TransloaditStorageAuthorizationContext { request: Request } -/** Application authorization for one exact private Storage object. */ +/** Return true to authorize one private object; thrown application errors propagate, not deny. */ export type AuthorizeTransloaditStorageImage = ( context: TransloaditStorageAuthorizationContext, ) => boolean | Promise @@ -47,85 +60,130 @@ export type AuthorizeTransloaditStorageImage = ( /** Request-authorized, byte-pass-through-free Storage delivery through a local route. */ export interface TransloaditStorageRedirectDelivery { authorize: AuthorizeTransloaditStorageImage + /** Server-declared public directories: use unsigned, direct CDN delivery. */ + public?: readonly string[] /** Next.js `basePath` prepended only to browser-facing route URLs. */ basePath?: string + /** Opt-in browser caching; capped at the rotation interval. Delays reauthorization. */ + cacheMaxAgeMs?: number /** Internal App Router path that exports `storageRoute`, for example `/api/private-images`. */ route: string } -/** Bounded request-time policy for private Storage previews. */ -export interface TransloaditStorageImageConfiguration { - /** Authorized directory prefixes. Defaults to deny-all; an empty prefix explicitly allows all. */ +/** A maximum grant age, in milliseconds or an explicit duration such as "1h" or "365d". */ +export type StorageImageLifetime = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` + +interface StorageImageOptions { + /** Trusted key override; otherwise resolved from the server environment on first use. */ + authKey?: string + /** Trusted secret override; otherwise resolved from the server environment on first use. */ + authSecret?: string + /** Catalog/explicit workspace fallback; TRANSLOADIT_WORKSPACE overrides it on first use. */ + workspace?: string + /** Catalog transport overrides, or direct signing inside a request-authorized page. */ + delivery?: 'direct' | { baseUrl?: string; urlParams?: SmartCdnUrlParams } + /** Catalog keys become typed src references; values provide intrinsic geometry. */ + images?: Catalog + /** Defaults to public and catalog directories, plus exact root-level catalog paths. */ allowedPathPrefixes?: readonly string[] - /** Direct signed CDN URLs are the default; an object opts into authorized redirect delivery. */ - delivery?: 'direct' | TransloaditStorageRedirectDelivery - /** Minimum lifetime of each CDN signature. Defaults to one hour. */ - expiresInMs?: number - /** Stable CDN-signature rotation bucket. Defaults to five minutes. */ + /** Explicitly allow every object in the workspace, including root-level paths. */ + allowWorkspaceRoot?: boolean + /** Server-declared public directories. These render unsigned URLs without keys or expiry. */ + public?: readonly string[] + /** Opt into a redirect handler; private objects require this check for every uncached request. */ + authorize?: AuthorizeTransloaditStorageImage + /** Defaults to /api/storage-images when authorize is provided. */ + route?: string + basePath?: string + /** Opt-in private browser caching, capped at rotation and remaining grant age. */ + cacheMaxAge?: StorageImageLifetime + /** @deprecated Use cacheMaxAge with a duration such as '1m'. */ + cacheMaxAgeMs?: number + /** Maximum private CDN grant age, at most 48h; defaults to 1h. Public URLs never expire. */ + lifetime?: StorageImageLifetime + /** Stable signature bucket, at most half the private lifetime. Defaults to min(lifetime / 2, one hour). */ + rotationInterval?: StorageImageLifetime + /** @deprecated Use rotationInterval with a duration such as '30m'. */ rotationIntervalMs?: number -} - -/** Server-only credentials and trusted Smart CDN configuration. */ -export interface TransloaditImageConfiguration { - authKey: string - authSecret: string /** Trusted development endpoint override; never derive this from request data. */ baseUrl?: string - storage: TransloaditStorageImageConfiguration /** Trusted compatible signed Template override for Storage previews. */ template?: string + /** Trusted unsigned public Template override; independent of the private Template. */ + publicTemplate?: string /** Trusted transport parameters appended to every signed URL, such as `cdn=required`. */ urlParams?: SmartCdnUrlParams - workspace: string } -/** Configuration that opts into a request-authorized Storage route. */ -export interface TransloaditRedirectImageConfiguration extends TransloaditImageConfiguration { - storage: TransloaditStorageImageConfiguration & { - delivery: TransloaditStorageRedirectDelivery - } -} +/** One flat policy with either a catalog, explicit prefixes (including deny-all []), or root access. */ +export type StorageImagesConfiguration< + Catalog extends StorageImageCatalog | undefined = undefined, +> = StorageImageOptions & + ( + | { images: Catalog extends undefined ? never : Catalog } + | { allowedPathPrefixes: readonly string[] } + | { allowWorkspaceRoot: true } + ) -interface CommonTransloaditImageProps extends TransloaditImagePresentationProps { - /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */ - widths?: readonly number[] +/** Request-authorized private delivery with the same flat configuration as direct delivery. */ +export type PrivateStorageImagesConfiguration< + Catalog extends StorageImageCatalog | undefined = undefined, +> = StorageImagesConfiguration & { + authorize: AuthorizeTransloaditStorageImage } -/** Props for a private Transloadit Storage preview. */ -export interface TransloaditImageProps - extends Omit { - /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ - fallbackQuality?: number - formats?: StoragePreviewFormats - media?: never - mediaPlaceholderSrc?: never - /** Relative object path inside the configured Transloadit Storage workspace. */ - src: string - /** Static shell used only while direct request-time signing is suspended. */ - suspenseFallback?: ReactNode -} +/** Props for a Transloadit Storage preview, optionally typed from a rendering catalog. */ +export type TransloaditImageProps = + TransloaditImageLayoutProps & + StorageImageLayoutProps & { + /** Opaque JPEG background as #rrggbb or #rrggbbff. Defaults to white. */ + fallbackBackground?: string + /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ + fallbackQuality?: number + formats?: StoragePreviewFormats + /** Opt-in receipt blur; request-authorized private redirects do not expose placeholder pixels. */ + placeholder?: 'blur' | 'empty' + /** Static shell used only while direct request-time signing is suspended. */ + suspenseFallback?: ReactNode + /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */ + widths?: readonly number[] + } + +/** Redirect images render synchronously and have no signing suspension to replace. */ +export type TransloaditRedirectImageProps< + Catalog extends StorageImageCatalog | undefined = undefined, +> = TransloaditImageProps & { suspenseFallback?: never } /** One configured Next.js Server Component for Transloadit Storage objects. */ -export type TransloaditImageComponent = (props: TransloaditImageProps) => ReactNode +export type TransloaditImageComponent = + (props: TransloaditImageProps) => ReactNode /** A Next.js route handler that authorizes and redirects one private image request. */ export type TransloaditStorageRoute = (request: Request) => Promise /** Direct-delivery integration. Image bytes and requests bypass the Next.js server. */ -export interface TransloaditImageIntegration { - Image: TransloaditImageComponent +export interface TransloaditImageIntegration< + Catalog extends StorageImageCatalog | undefined = undefined, +> { + StorageImage: TransloaditImageComponent } /** Redirect-delivery integration with a route handler for private Storage images. */ -export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration { +export interface TransloaditRedirectImageIntegration< + Catalog extends StorageImageCatalog | undefined = undefined, +> { + StorageImage: (props: TransloaditRedirectImageProps) => ReactNode storageRoute: TransloaditStorageRoute } interface ResolvedStoragePolicy { allowedPathPrefixes: readonly string[] + allowedPaths: ReadonlySet delivery: 'direct' | TransloaditStorageRedirectDelivery - expiresInMs: number - rotationIntervalMs: number + images?: StorageImageCatalog + lifetime?: number + public: readonly string[] + rotationIntervalMs?: number } interface ResolvedStorageCapabilityPolicy { @@ -135,18 +193,59 @@ interface ResolvedStorageCapabilityPolicy { } interface StorageImageTransform { + background?: string format: 'avif' | 'jpg' | 'png' | 'webp' height: number path: string quality: number + strategy?: 'fillcrop' width: number } interface TransloaditStorageImageRequestProps { - props: TransloaditImageProps + props: ResolvedStorageImageProps +} + +type ResolvedStorageImageProps = TransloaditImagePresentationProps & { + source: ReturnType['source'] + cropAspectRatio?: number + artDirection?: ReturnType['artDirection'] + frame?: ReturnType['frame'] + diagnoseSize?: boolean + fallbackWidth?: number + maximumWidth?: number + fallbackBackground?: string + fallbackQuality?: number + formats?: StoragePreviewFormats + placeholder?: 'blur' | 'empty' + suspenseFallback?: ReactNode + widths?: readonly number[] +} + +function StorageImagePlaceholder({ props }: TransloaditStorageImageRequestProps): ReactNode { + const attributes = snapshotImageAttributes(props) + // Streaming briefly keeps both elements in the DOM; identity and accessibility belong to the + // resolved image, not to the decorative shell that React will remove. + const placeholderAttributes = Object.fromEntries( + Object.entries(attributes).filter(([name]) => name !== 'id' && !name.startsWith('aria-')), + ) + return ( + + + + + + ) } -function validateRequiredConfiguration(value: string, name: string): void { +function validateRequiredConfiguration(value: unknown, name: string): asserts value is string { if (typeof value !== 'string' || value === '' || value.trim() !== value) { throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`) } @@ -197,7 +296,7 @@ function validateGlobalUrlParams(urlParams: SmartCdnUrlParams | undefined): void } function validateStorageRoute(route: string): void { - const error = new TypeError('storage.delivery.route must be one absolute application path') + const error = new TypeError('route must be one absolute application path') if ( typeof route !== 'string' || !route.startsWith('/') || @@ -212,9 +311,7 @@ function validateStorageRoute(route: string): void { function validateStorageBasePath(basePath: string | undefined): void { if (basePath === undefined) return - const error = new TypeError( - 'storage.delivery.basePath must be one absolute path without a trailing slash', - ) + const error = new TypeError('basePath must be one absolute path without a trailing slash') if ( typeof basePath !== 'string' || basePath === '' || @@ -246,59 +343,186 @@ function matchesStorageRoute(path: string, delivery: TransloaditStorageRedirectD ) } +function parseLifetime( + lifetime: StorageImageLifetime | undefined, + name = 'lifetime', +): number | undefined { + if (lifetime === undefined) return undefined + const units = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } + if (typeof lifetime === 'number') { + validateDuration(lifetime, name) + return lifetime + } + if (typeof lifetime !== 'string') + throw new TypeError(`${name} must be milliseconds or a duration such as "1h"`) + const parts = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(lifetime) + const unit = parts?.[2] + if (parts === null || unit === undefined || !(unit in units)) + throw new TypeError(`${name} must be milliseconds or a duration such as "1h"`) + const multiplier = Object.entries(units).find(([name]) => name === unit)?.[1] + if (multiplier === undefined) throw new TypeError('Unsupported lifetime unit') + const duration = Number(parts[1]) * multiplier + validateDuration(duration, name) + return duration +} + +function validatePrefixes(prefixes: readonly string[], name: string): readonly string[] { + if (!Array.isArray(prefixes)) throw new TypeError(`${name} must be an array of explicit prefixes`) + for (const [index, prefix] of prefixes.entries()) { + validateStoragePathPrefix(prefix, index, name) + if (prefix === '') + throw new TypeError( + `${name} cannot contain an empty prefix; use allowWorkspaceRoot: true for workspace-wide access`, + ) + } + return [...new Set(prefixes)] +} + function getStoragePolicy( - configuration: TransloaditStorageImageConfiguration, + configuration: StorageImagesConfiguration, ): ResolvedStoragePolicy { - const allowedPathPrefixes = configuration.allowedPathPrefixes ?? [] - const delivery = configuration.delivery ?? 'direct' - const expiresInMs = configuration.expiresInMs ?? defaultStorageExpiresInMs - const rotationIntervalMs = configuration.rotationIntervalMs ?? defaultStorageRotationIntervalMs - if (!Array.isArray(allowedPathPrefixes)) { - throw new TypeError('storage.allowedPathPrefixes must be an array') - } - const validatedPathPrefixes = new Set() - for (const [index, prefix] of allowedPathPrefixes.entries()) { - validateStoragePathPrefix(prefix, index) - validatedPathPrefixes.add(prefix) - } - validateDuration(expiresInMs, 'storage.expiresInMs') - validateDuration(rotationIntervalMs, 'storage.rotationIntervalMs') - if (expiresInMs + rotationIntervalMs > maximumStorageLifetimeMs) { - throw new RangeError('Storage image expiry plus its rotation interval must not exceed 48 hours') - } - if (delivery !== 'direct') { - if (typeof delivery !== 'object' || delivery === null || Array.isArray(delivery)) { - throw new TypeError('storage.delivery must be direct or a redirect configuration') + if (typeof configuration !== 'object' || configuration === null || Array.isArray(configuration)) { + throw new TypeError('Storage images require an explicit configuration object') + } + const catalog = configuration.images + let images: StorageImageCatalog | undefined + if (catalog !== undefined) { + if (!isRecord(catalog)) throw new TypeError('images must be a rendering catalog') + images = Object.fromEntries( + Object.entries(catalog).map(([path, source]) => { + const receipt = snapshotImageSource({ src: source }) + if (path !== receipt.path) + throw new TypeError('Each catalog key must equal its receipt path') + return [path, receipt] + }), + ) + } + if ( + configuration.allowWorkspaceRoot !== undefined && + typeof configuration.allowWorkspaceRoot !== 'boolean' + ) + throw new TypeError('allowWorkspaceRoot must be a boolean') + if ( + configuration.allowedPathPrefixes === undefined && + images === undefined && + configuration.allowWorkspaceRoot !== true + ) + throw new TypeError('images, allowedPathPrefixes or allowWorkspaceRoot: true is required') + const inferredPrefixes = new Set() + const allowedPaths = new Set() + if (configuration.allowedPathPrefixes === undefined) { + for (const path of Object.keys(images ?? {})) { + const separator = path.lastIndexOf('/') + if (separator === -1) allowedPaths.add(path) + else inferredPrefixes.add(path.slice(0, separator + 1)) } - validateStorageRoute(delivery.route) - validateStorageBasePath(delivery.basePath) - if (typeof delivery.authorize !== 'function') { - throw new TypeError('storage.delivery.authorize must be a function') + } + const publicPrefixes = validatePrefixes(configuration.public ?? [], 'public') + const allowedPathPrefixes = validatePrefixes( + configuration.allowedPathPrefixes ?? [...inferredPrefixes, ...publicPrefixes], + 'allowedPathPrefixes', + ) + const resolvedPrefixes = configuration.allowWorkspaceRoot === true ? [''] : allowedPathPrefixes + for (const prefix of publicPrefixes) { + if (new TextEncoder().encode(prefix).byteLength > 512) + throw new TypeError('public prefixes must not exceed 512 UTF-8 bytes') + if (!resolvedPrefixes.some((allowed) => prefix.startsWith(allowed))) + throw new TypeError('public prefixes must be within allowedPathPrefixes') + } + if ( + configuration.authorize === undefined && + publicPrefixes.length === 0 && + configuration.delivery !== 'direct' + ) + throw new TypeError( + "No public prefixes are configured. Publish a directory with storage publish only if it should be public; otherwise configure private authorization. Choose public, authorize, or delivery: 'direct' for Storage images.", + ) + if (configuration.delivery !== undefined && configuration.delivery !== 'direct') + throw new TypeError("delivery must be 'direct'; provide authorize to enable redirects") + const lifetime = parseLifetime(configuration.lifetime) + if (lifetime !== undefined && lifetime > maximumStorageLifetimeMs) + throw new RangeError( + 'Private Storage image lifetime must not exceed 48 hours; public URLs do not use lifetime', + ) + if ( + configuration.rotationInterval !== undefined && + configuration.rotationIntervalMs !== undefined + ) + throw new TypeError('Use rotationInterval or rotationIntervalMs, not both') + if (configuration.cacheMaxAge !== undefined && configuration.cacheMaxAgeMs !== undefined) + throw new TypeError('Use cacheMaxAge or cacheMaxAgeMs, not both') + const rotationIntervalMs = parseLifetime( + configuration.rotationInterval ?? configuration.rotationIntervalMs, + 'rotationInterval', + ) + const cacheMaxAgeMs = parseLifetime( + configuration.cacheMaxAge ?? configuration.cacheMaxAgeMs, + 'cacheMaxAge', + ) + if (rotationIntervalMs !== undefined) { + validateDuration(rotationIntervalMs, 'rotationIntervalMs') + if ( + rotationIntervalMs > + Math.min(lifetime ?? defaultStorageExpiresInMs, maximumStorageLifetimeMs) / 2 + ) + throw new RangeError( + 'rotationIntervalMs must not exceed half the private lifetime (capped at 48 hours)', + ) + } + const basePath = configuration.authorize !== undefined ? configuration.basePath : undefined + let delivery: ResolvedStoragePolicy['delivery'] = 'direct' + if (configuration.authorize !== undefined) { + // An authorizer overrides a shared direct-delivery default; private access stays gated. + const route = configuration.route ?? '/api/storage-images' + validateStorageRoute(route) + validateStorageBasePath(basePath) + if (typeof configuration.authorize !== 'function') { + throw new TypeError('authorize must be a function') + } + delivery = { + authorize: configuration.authorize, + basePath, + cacheMaxAgeMs, + public: publicPrefixes, + route, } + } else if ( + configuration.route !== undefined || + configuration.basePath !== undefined || + cacheMaxAgeMs !== undefined + ) { + throw new TypeError('route, basePath and cacheMaxAgeMs require an authorize function') } return { - allowedPathPrefixes: [...validatedPathPrefixes], - delivery: - delivery === 'direct' - ? delivery - : { - authorize: delivery.authorize, - basePath: delivery.basePath, - route: delivery.route, - }, - expiresInMs, + allowedPathPrefixes: resolvedPrefixes, + allowedPaths, + delivery, + images, + lifetime, + public: publicPrefixes, rotationIntervalMs, } } +function getGrantPolicy(policy: ResolvedStoragePolicy): { lifetime: number; rotation: number } { + const lifetime = policy.lifetime ?? defaultStorageExpiresInMs + const rotation = + policy.rotationIntervalMs ?? + Math.max(1, Math.min(Math.floor(lifetime / 2), defaultStorageExpiresInMs)) + return { lifetime, rotation } +} + function getStorageExpiresAt(now: number, policy: ResolvedStoragePolicy): number { - const nextRotation = (Math.floor(now / policy.rotationIntervalMs) + 1) * policy.rotationIntervalMs - return nextRotation + policy.expiresInMs + const { lifetime, rotation } = getGrantPolicy(policy) + return Math.floor(now / rotation) * rotation + lifetime } function assertAllowedStoragePath(path: string, policy: ResolvedStoragePolicy): void { validateStoragePath(path) - if (!policy.allowedPathPrefixes.some((prefix) => path.startsWith(prefix))) { + if ( + !policy.allowedPaths.has(path) && + !policy.allowedPathPrefixes.some((prefix) => path.startsWith(prefix)) + ) { throw new TypeError('Storage image path is outside the configured allowed prefixes') } } @@ -314,73 +538,193 @@ function snapshotUrlParams( return snapshot } +function previewUrlParams(template: string, parameters: SmartCdnUrlParams): SmartCdnUrlParams { + // These exact versions share API2's defaults. Customer templates (and future Built-ins) may not. + if (template !== 'builtin/storage-preview@0.0.2' && template !== 'builtin/public-preview@0.0.1') + return parameters + const defaults: Readonly> = { + bg: '#ffffff', + f: 'jpg', + q: 75, + r: 'pad', + } + return Object.fromEntries( + Object.entries(parameters).filter( + ([name, value]) => !Object.hasOwn(defaults, name) || defaults[name] !== value, + ), + ) +} + function snapshotStorageImageProps( - props: TransloaditImageProps, - path: string, -): TransloaditImageProps { + props: TransloaditImageProps, + layout: ReturnType, +): ResolvedStorageImageProps { + const attributes = snapshotImageAttributes(props) + const loading = snapshotImageLoading(props) + const lazy = loading.loading !== 'eager' && loading.preload !== true return { - alt: props.alt, - className: props.className, - deferUntilHydrated: props.deferUntilHydrated, + ...attributes, + ...loading, + artDirection: layout.artDirection, + frame: layout.frame, + diagnoseSize: process.env.NODE_ENV === 'development' && props.sizes === undefined, + cropAspectRatio: layout.cropAspectRatio, + errorFallback: props.errorFallback, + retryKey: props.retryKey, + fallbackBackground: props.fallbackBackground, fallbackQuality: props.fallbackQuality, - fetchPriority: props.fetchPriority, + fallbackWidth: layout.fallbackWidth, formats: props.formats === undefined ? undefined : { ...props.formats }, - height: props.height, - loading: props.loading, + height: layout.height, + maximumWidth: layout.maximumWidth, objectFit: props.objectFit, - preload: props.preload, - sizes: props.sizes, - src: path, - style: props.style === undefined ? undefined : { ...props.style }, + placeholder: props.placeholder, + source: layout.source, + sizes: + attributes.sizes ?? + ((props.layout === undefined || props.layout === 'constrained') && + lazy && + layout.sizes !== undefined + ? `auto, ${layout.sizes}` + : layout.sizes), + style: { ...layout.style, ...attributes.style }, suspenseFallback: props.suspenseFallback, - width: props.width, - widths: Array.isArray(props.widths) ? [...props.widths] : props.widths, + width: layout.width, + widths: layout.widths, } } -function getStoragePath(src: unknown): string { - if (typeof src !== 'string') { - throw new TypeError('Storage image src must be one relative object path') - } - return src +interface DevelopmentDeliveryResultProps { + result: Promise +} + +function DevelopmentDeliveryResult({ result }: DevelopmentDeliveryResultProps): ReactNode { + return {use(result)}. See the terminal for details. } function renderPicture( - props: CommonTransloaditImageProps, + props: ResolvedStorageImageProps, model: Parameters[0]['model'], + diagnostic?: Promise, + inlinePixels = false, ): ReactNode { + let blurDataURL: string | undefined + if (props.placeholder === 'blur') { + const hash = props.source.thumbhash + // Receipt metadata can be hand-edited. Bound decoding and reject malformed base64/geometry. + const bytes = + typeof hash === 'string' && hash.length <= 48 && /^[A-Za-z0-9+/]+={0,2}$/.test(hash) + ? Buffer.from(hash, 'base64') + : undefined + // Preserve transparency even if a hand-edited receipt lost its original alpha metadata. + const hasAlpha = props.source.hasAlpha === true || ((bytes?.[2] ?? 0) & 0x80) !== 0 + if ( + inlinePixels && + !hasAlpha && + bytes !== undefined && + bytes.length >= 17 && + bytes.length <= 25 && + bytes.toString('base64') === hash && + ((bytes[3] ?? 0) & 7) > 0 + ) { + blurDataURL = thumbHashToDataURL(bytes) + } else if (process.env.NODE_ENV === 'development') { + console.warn( + !inlinePixels + ? `[StorageImage] ${JSON.stringify(props.source.path)} uses request-authorized private delivery; placeholder="blur" is a no-op so its pixels are not exposed before authorization.` + : hasAlpha + ? `[StorageImage] ${JSON.stringify(props.source.path)}: transparent image: no blur placeholder.` + : `[StorageImage] ${JSON.stringify(props.source.path)} has no usable thumbhash; placeholder="blur" is a no-op. Use storage store with the original bytes to generate it.`, + ) + } + } + const errorFallback = + props.errorFallback === undefined || process.env.NODE_ENV !== 'development' ? ( + props.errorFallback + ) : ( + <> + {props.errorFallback} + {diagnostic === undefined ? ( + See the terminal for details. + ) : ( + Checking delivery; see the terminal for details.}> + + + )} + + ) + const picture = ( + + + + ) + return props.diagnoseSize ? {picture} : picture +} + +interface StorageImageFrameProps { + props: ResolvedStorageImageProps + children: ReactNode +} + +function StorageImageFrame({ props, children }: StorageImageFrameProps): ReactNode { + const frame = props.frame + if (frame === undefined) return children + // Values are validated numeric ratios/width queries, never arbitrary caller CSS. Reverse the + // rules so overlapping breakpoints follow picture's first-matching-source precedence. + const name = `tli-${createHash('sha256').update(JSON.stringify(frame)).digest('hex').slice(0, 16)}` + const selector = `.${name}` + const css = `${selector}{display:block;position:relative;width:100%;aspect-ratio:${frame.ratio}}${[ + ...frame.variants, + ] + .reverse() + .map( + ({ media, cropAspectRatio }) => + `@media ${media}{${selector}{aspect-ratio:${cropAspectRatio}}}`, + ) + .join('')}` return ( - + <> + + {children} + ) } -function getStorageTransform(request: SmartCdnImageSignRequest): StorageImageTransform { - const { f: format, h: height, q: quality, r: strategy, w: width } = request.urlParams +function getStorageTransform( + request: Omit, +): StorageImageTransform { + const { + bg: background, + f: format, + h: height, + q: quality, + r: strategy, + w: width, + } = request.urlParams if ( (format !== 'avif' && format !== 'jpg' && format !== 'png' && format !== 'webp') || + typeof background !== 'string' || typeof height !== 'number' || typeof quality !== 'number' || - strategy !== 'pad' || + (strategy !== 'pad' && strategy !== 'fillcrop') || typeof width !== 'number' ) { throw new TypeError('Storage image model produced an unsupported transform') } - return { format, height, path: request.input, quality, width } + return { + background, + format, + height, + path: request.input, + quality, + width, + ...(strategy === 'fillcrop' ? { strategy } : {}), + } } function createStorageRouteKey(authSecret: string, workspace: string): Buffer { @@ -389,13 +733,14 @@ function createStorageRouteKey(authSecret: string, workspace: string): Buffer { function getStorageCapabilityContext( delivery: TransloaditStorageRedirectDelivery, - template: string, + customTemplate: string | undefined, workspace: string, ): string { return JSON.stringify([ storageRouteKeyDomain, + storageCapabilityVersion, workspace, - template, + customTemplate ?? null, delivery.route, getBrowserStorageRoute(delivery), ]) @@ -417,7 +762,7 @@ function getStorageRouteUrl( context: string, delivery: TransloaditStorageRedirectDelivery, key: Buffer, - request: SmartCdnImageSignRequest, + request: Omit, ): string { const capability = encryptStorageCapability(context, key, getStorageTransform(request)) return `${getBrowserStorageRoute(delivery)}?${new URLSearchParams({ cap: capability })}` @@ -433,9 +778,14 @@ function isStorageRouteFormat(value: unknown): value is StorageImageTransform['f function getStorageTransformFromPayload(payload: unknown): StorageImageTransform | undefined { if (!isRecord(payload) || payload.version !== storageCapabilityVersion) return undefined - const { format, height, path, quality, width } = payload + const { background, format, height, path, quality, width, strategy } = payload if ( !isStorageRouteFormat(format) || + (background !== undefined && + (format === 'jpg' + ? !isOpaqueImageBackground(background) + : background !== transparentImageBackground)) || + (strategy !== undefined && strategy !== 'fillcrop') || typeof height !== 'number' || !Number.isInteger(height) || height < 1 || @@ -453,7 +803,15 @@ function getStorageTransformFromPayload(payload: unknown): StorageImageTransform return undefined } validateStoragePath(path) - return { format, height, path, quality, width } + return { + ...(typeof background === 'string' ? { background } : {}), + format, + height, + path, + quality, + width, + ...(strategy === 'fillcrop' ? { strategy } : {}), + } } function decryptStorageCapability( @@ -495,13 +853,6 @@ function parseStorageRouteTransform( return decryptStorageCapability(url.searchParams.get('cap'), context, key) } -function notFound(): Response { - return new Response(null, { - headers: { 'Cache-Control': 'private, no-store' }, - status: 404, - }) -} - function createStorageRoute( context: string, delivery: TransloaditStorageRedirectDelivery, @@ -509,7 +860,35 @@ function createStorageRoute( policy: ResolvedStoragePolicy, sign: (request: SmartCdnImageSignRequest) => string, template: string, + diagnose: DiagnoseStorageImage | undefined, + buildPublicUrl: ( + request: Omit, + md5hash?: string, + ) => string, ): TransloaditStorageRoute { + const reasons = { + route: + 'Redirect route/basePath differs from this handler. Check the route export and rebuild cached markup.', + capability: + 'Invalid or stale image capability. The signing secret, route/basePath, custom template or capability contract may have changed. Refresh cached markup.', + prefix: + 'The requested object is outside allowedPathPrefixes. Check the current signing policy.', + authorization: + 'Application authorization denied this image. Check the session and per-object access policy.', + } + const explained = new Set() + function notFound(reason: keyof typeof reasons, path?: string): Response { + const key = JSON.stringify([reason, path]) + if (process.env.NODE_ENV === 'development' && !explained.has(key)) { + explained.add(key) + const publication = + reason === 'authorization' && path !== undefined + ? ` Storage path ${JSON.stringify(path)} is not under a public prefix in the current image configuration. ${publishImageHint(path)}` + : '' + console.warn(`[StorageImage] ${reasons[reason]}${publication}`) + } + return new Response(null, { headers: { 'Cache-Control': 'private, no-store' }, status: 404 }) + } return async function storageRoute(request: Request): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response(null, { @@ -518,31 +897,66 @@ function createStorageRoute( }) } const url = new URL(request.url) - if (!matchesStorageRoute(url.pathname, delivery)) return notFound() + if (!matchesStorageRoute(url.pathname, delivery)) return notFound('route') const transform = parseStorageRouteTransform(url, context, key) - if (transform === undefined) return notFound() + if (transform === undefined) return notFound('capability') try { assertAllowedStoragePath(transform.path, policy) } catch { - return notFound() + return notFound('prefix') } - if ((await delivery.authorize({ path: transform.path, request })) !== true) return notFound() + const path = transform.path + const isPublic = delivery.public?.some((prefix) => path.startsWith(prefix)) === true + if (!isPublic && (await delivery.authorize({ path: transform.path, request })) !== true) + return notFound('authorization', path) - const location = sign({ - expiresAt: getStorageExpiresAt(Date.now(), policy), + const signRequest = { input: transform.path, template, urlParams: { + ...(transform.background === undefined ? {} : { bg: transform.background }), f: transform.format, h: transform.height, q: transform.quality, - r: 'pad', + r: transform.strategy ?? 'pad', w: transform.width, }, - }) + } + if (isPublic) { + const source = + policy.images !== undefined && Object.hasOwn(policy.images, path) + ? policy.images[path] + : undefined + const location = buildPublicUrl(signRequest, source?.md5hash) + diagnose?.( + path, + location, + policy.public.find((prefix) => path.startsWith(prefix)), + ) + return new Response(null, { + status: 307, + headers: { + Location: location, + // Old private capabilities carry no receipt hash; bound stale Locations after overwrite. + // Newly rendered public images bypass this compatibility route with versioned CDN URLs. + 'Cache-Control': 'public, max-age=0, s-maxage=60', + 'Referrer-Policy': 'no-referrer', + }, + }) + } + const now = Date.now() + const expiresAt = getStorageExpiresAt(now, policy) + const { rotation } = getGrantPolicy(policy) + const cacheSeconds = Math.max( + 0, + Math.floor(Math.min(delivery.cacheMaxAgeMs ?? 0, rotation, expiresAt - now) / 1000), + ) + const location = sign({ ...signRequest, expiresAt }) + diagnose?.(path, location) return new Response(null, { headers: { - 'Cache-Control': 'private, no-store', + 'Cache-Control': + cacheSeconds > 0 ? `private, max-age=${cacheSeconds}` : 'private, no-store', Location: location, 'Referrer-Policy': 'no-referrer', }, @@ -551,131 +965,265 @@ function createStorageRoute( } } -/** Creates one credentialed Next.js image integration without reading application environment. */ -export function createTransloaditImage( - configuration: TransloaditRedirectImageConfiguration, -): TransloaditRedirectImageIntegration -export function createTransloaditImage( - configuration: TransloaditImageConfiguration, -): TransloaditImageIntegration -export function createTransloaditImage( - configuration: TransloaditImageConfiguration, -): TransloaditImageIntegration | TransloaditRedirectImageIntegration { - const authKey = configuration.authKey - const authSecret = configuration.authSecret +function createImageIntegration( + configuration: StorageImagesConfiguration, + storagePolicy: ResolvedStoragePolicy, + getCredentials: () => { authKey: string; authSecret: string; workspace: string }, + getWorkspace: () => string, +): TransloaditImageIntegration | TransloaditRedirectImageIntegration { const baseUrl = configuration.baseUrl const storageTemplate = configuration.template ?? transloaditStoragePreviewTemplate + const customTemplate = configuration.template const urlParams = snapshotUrlParams(configuration.urlParams) - const workspace = configuration.workspace - validateRequiredConfiguration(authKey, 'authKey') - validateRequiredConfiguration(authSecret, 'authSecret') - validateRequiredConfiguration(workspace, 'workspace') validateBaseUrl(baseUrl) validateTemplate(storageTemplate, 'template') validateGlobalUrlParams(urlParams) - - const storagePolicy = getStoragePolicy(configuration.storage) - // Redirect capabilities do not encode this value; keeping one factory snapshot makes their - // prerendered markup deterministic while request-time CDN signatures rotate independently. - const storageCapabilityModelExpiresAt = getStorageExpiresAt(Date.now(), storagePolicy) + const diagnose = createImageDiagnostics(storageTemplate) + const privateDirect = configuration.delivery === 'direct' const sign = (request: SmartCdnImageSignRequest): string => getSignedSmartCdnUrl({ - authKey, - authSecret, + ...getCredentials(), baseUrl, expiresAt: request.expiresAt, input: request.input, template: request.template, - urlParams: { ...urlParams, ...request.urlParams }, - workspace, + urlParams: previewUrlParams(request.template, { ...urlParams, ...request.urlParams }), }) - const storageCapability: ResolvedStorageCapabilityPolicy | undefined = - storagePolicy.delivery === 'direct' - ? undefined - : { - context: getStorageCapabilityContext(storagePolicy.delivery, storageTemplate, workspace), - delivery: storagePolicy.delivery, - key: createStorageRouteKey(authSecret, workspace), - } - const buildStorageUrl = - storageCapability === undefined - ? sign - : (request: SmartCdnImageSignRequest): string => - getStorageRouteUrl( - storageCapability.context, - storageCapability.delivery, - storageCapability.key, - request, - ) + const publicTemplate = configuration.publicTemplate ?? transloaditPublicStoragePreviewTemplate + validateTemplate(publicTemplate, 'publicTemplate') + const buildPublicUrl = ( + request: Omit, + md5hash?: string, + ): string => + getSmartCdnUrl({ + workspace: getWorkspace(), + baseUrl, + template: publicTemplate, + input: request.input, + urlParams: previewUrlParams(publicTemplate, { + ...urlParams, + ...request.urlParams, + ...(md5hash === undefined ? {} : { v: md5hash.slice(0, 16) }), + }), + }) + const redirectDelivery = storagePolicy.delivery + let storageCapability: ResolvedStorageCapabilityPolicy | undefined + function getCapability(): ResolvedStorageCapabilityPolicy { + if (redirectDelivery === 'direct') + throw new Error('Direct images do not use route capabilities') + if (storageCapability === undefined) { + const { authSecret, workspace } = getCredentials() + storageCapability = { + context: getStorageCapabilityContext(redirectDelivery, customTemplate, workspace), + delivery: redirectDelivery, + key: createStorageRouteKey(authSecret, workspace), + } + } + return storageCapability + } + const buildStorageUrl = (request: Omit): string => { + const capability = getCapability() + return getStorageRouteUrl(capability.context, capability.delivery, capability.key, request) + } - async function DirectStorageImage({ - props, - }: TransloaditStorageImageRequestProps): Promise { - await connection() + function createModel( + props: ResolvedStorageImageProps, + expiresAt: Expiry, + resolveUrl: (request: SmartCdnImageSignRequest) => string, + template = storageTemplate, + ): TransloaditImageModel { const model = createTransloaditImageModel( { - expiresAt: getStorageExpiresAt(Date.now(), storagePolicy), + cropAspectRatio: props.cropAspectRatio, + expiresAt, + fallbackBackground: props.fallbackBackground, fallbackQuality: props.fallbackQuality, + fallbackWidth: props.fallbackWidth, formats: props.formats, - height: props.height, - src: props.src, - template: storageTemplate, - width: props.width, + maximumWidth: props.maximumWidth, + src: props.source, + template, widths: props.widths, }, - sign, + resolveUrl, ) - return renderPicture(props, model) + if (props.artDirection === undefined || props.artDirection.length === 0) return model + return { + ...model, + artDirection: props.artDirection.map(({ media, cropAspectRatio }) => ({ + media, + model: createModel( + { ...props, cropAspectRatio, artDirection: undefined }, + expiresAt, + resolveUrl, + template, + ), + })), + } } - function Image(props: TransloaditImageProps): ReactNode { - const storagePath = getStoragePath(props.src) - if (props.media !== undefined) { - throw new TypeError('Storage image previews do not support media conditions') + let explainedDirectDelivery = false + async function DirectStorageImage({ + props, + }: TransloaditStorageImageRequestProps): Promise { + await connection() + if (process.env.NODE_ENV === 'development' && !explainedDirectDelivery) { + explainedDirectDelivery = true + console.info( + 'StorageImage (direct) makes this route dynamic; use redirect delivery for static pages', + ) } - assertAllowedStoragePath(storagePath, storagePolicy) - const storageProps = snapshotStorageImageProps(props, storagePath) - if (storageCapability === undefined) { + const model = createModel(props, getStorageExpiresAt(Date.now(), storagePolicy), sign) + const diagnostic = diagnose?.( + props.source.path, + model.sources[0]?.candidates[0]?.url ?? model.fallbackUrl, + ) + return renderPicture(props, model, diagnostic, true) + } + + function StorageImage(props: TransloaditImageProps): ReactNode { + const layout = resolveImageLayout(props, storagePolicy.images) + assertAllowedStoragePath(layout.source.path, storagePolicy) + const storageProps = snapshotStorageImageProps(props, layout) + const publicPrefix = storagePolicy.public.find((prefix) => + layout.source.path.startsWith(prefix), + ) + if (publicPrefix !== undefined) { + const model = createModel( + storageProps, + undefined, + (request) => buildPublicUrl(request, layout.source.md5hash), + publicTemplate, + ) + const diagnostic = diagnose?.( + layout.source.path, + model.sources[0]?.candidates[0]?.url ?? model.fallbackUrl, + publicPrefix, + ) + return renderPicture(storageProps, model, diagnostic, true) + } + if (redirectDelivery === 'direct') { + if (!privateDirect) + throw new TypeError("Private images require authorize or delivery: 'direct'") return ( - + + ) : ( + storageProps.suspenseFallback + ) + } + > ) } - if (props.suspenseFallback !== undefined) { + if (storageProps.suspenseFallback !== undefined) { throw new TypeError('suspenseFallback is only used by direct Storage delivery') } - const resolvedModel = createTransloaditImageModel( - { - expiresAt: storageCapabilityModelExpiresAt, - fallbackQuality: props.fallbackQuality, - formats: props.formats, - height: props.height, - src: storagePath, - template: storageTemplate, - width: props.width, - widths: props.widths, - }, - buildStorageUrl, - ) + const resolvedModel = createModel(storageProps, undefined, buildStorageUrl) const model: TransloaditImageModel = { + artDirection: resolvedModel.artDirection, fallbackUrl: resolvedModel.fallbackUrl, sources: resolvedModel.sources, } return renderPicture(storageProps, model) } - const integration: TransloaditImageIntegration = { Image } - if (storageCapability === undefined) return integration + const integration: TransloaditImageIntegration = { StorageImage } + if (redirectDelivery === 'direct') return integration + let route: TransloaditStorageRoute | undefined return { ...integration, - storageRoute: createStorageRoute( - storageCapability.context, - storageCapability.delivery, - storageCapability.key, - storagePolicy, - sign, - storageTemplate, - ), + async storageRoute(request) { + if (route === undefined) { + const capability = getCapability() + route = createStorageRoute( + capability.context, + capability.delivery, + capability.key, + storagePolicy, + sign, + storageTemplate, + diagnose, + buildPublicUrl, + ) + } + return await route(request) + }, } } + +/** Reads rendering credentials once on first render/request, not while importing the factory. */ +export function createStorageImages( + configuration: PrivateStorageImagesConfiguration, +): TransloaditRedirectImageIntegration +export function createStorageImages( + configuration: StorageImagesConfiguration, +): TransloaditImageIntegration +export function createStorageImages( + input: StorageImagesConfiguration, +): TransloaditImageIntegration | TransloaditRedirectImageIntegration { + const configuration = + typeof input?.delivery === 'object' && input.delivery !== null + ? { + ...input, + baseUrl: input.baseUrl ?? input.delivery.baseUrl, + urlParams: input.urlParams ?? input.delivery.urlParams, + delivery: undefined, + } + : input + const policy = getStoragePolicy(configuration) + const explicit = { + authKey: configuration.authKey, + authSecret: configuration.authSecret, + workspace: configuration.workspace, + } + if (configuration.authorize !== undefined || configuration.delivery === 'direct') + for (const [name, value] of Object.entries(explicit)) + if (value !== undefined) validateRequiredConfiguration(value, name) + let workspace: string | undefined + function getWorkspace(): string { + if (workspace === undefined) { + const value = process.env.TRANSLOADIT_WORKSPACE || explicit.workspace + validateRequiredConfiguration(value, 'TRANSLOADIT_WORKSPACE') + workspace = value + } + return workspace + } + let credentials: { authKey: string; authSecret: string; workspace: string } | undefined + return createImageIntegration( + configuration, + policy, + () => { + if (credentials === undefined) { + const separate = + process.env.TRANSLOADIT_SMART_CDN_KEY !== undefined || + process.env.TRANSLOADIT_SMART_CDN_SECRET !== undefined + const explicitPair = explicit.authKey !== undefined || explicit.authSecret !== undefined + const keyName = explicitPair + ? 'authKey' + : separate + ? 'TRANSLOADIT_SMART_CDN_KEY' + : 'TRANSLOADIT_KEY' + const secretName = explicitPair + ? 'authSecret' + : separate + ? 'TRANSLOADIT_SMART_CDN_SECRET' + : 'TRANSLOADIT_SECRET' + const authKey = explicitPair ? explicit.authKey : process.env[keyName] + const authSecret = explicitPair ? explicit.authSecret : process.env[secretName] + if (!explicitPair && authKey === undefined && authSecret === undefined) + throw new TypeError( + 'Private images need a signing key. Set TRANSLOADIT_SMART_CDN_KEY and TRANSLOADIT_SMART_CDN_SECRET (Console → Credentials → New Auth Key → “Private image delivery”). TRANSLOADIT_KEY/SECRET are also accepted.', + ) + validateRequiredConfiguration(authKey, keyName) + validateRequiredConfiguration(authSecret, secretName) + credentials = { authKey, authSecret, workspace: getWorkspace() } + } + return credentials + }, + getWorkspace, + ) +} diff --git a/packages/img/test/conventional.test.tsx b/packages/img/test/conventional.test.tsx new file mode 100644 index 00000000..105d0760 --- /dev/null +++ b/packages/img/test/conventional.test.tsx @@ -0,0 +1,178 @@ +import type { StorageProjectCatalog } from '../src/next/catalog.ts' +import type { AuthorizeTransloaditStorageImage } from '../src/next/server.tsx' + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { Window } from 'happy-dom' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const project = vi.hoisted<{ + catalog: StorageProjectCatalog + authorize: AuthorizeTransloaditStorageImage | undefined + authorizePath?: string +}>(() => ({ + catalog: { + workspace: 'catalog-app', + public: ['website/'], + images: { + 'website/hero.jpg': { path: 'website/hero.jpg', width: 1200, height: 800 }, + 'uploads/avatar.png': { path: 'uploads/avatar.png', width: 96, height: 96 }, + }, + delivery: undefined, + }, + authorize: undefined, +})) +const window = new Window() +vi.mock('server-only', () => ({})) +vi.mock('@transloadit/img/next/catalog', () => ({ + get default() { + return project.catalog + }, +})) +vi.mock('@transloadit/img/next/authorize', () => ({ + get authorize() { + return project.authorize + }, +})) +vi.mock('@transloadit/img/next/options', () => ({ + default: { + diagnosticsId: 'conventional-test', + get authorizePath() { + return project.authorizePath + }, + }, +})) + +beforeEach(() => { + vi.resetModules() + vi.stubEnv('NODE_ENV', 'production') + for (const name of [ + 'TRANSLOADIT_WORKSPACE', + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_SMART_CDN_KEY', + 'TRANSLOADIT_SMART_CDN_SECRET', + ]) + vi.stubEnv(name, undefined) + project.authorize = undefined + project.authorizePath = undefined + project.catalog.public = ['website/'] + project.catalog.delivery = undefined +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +test('package StorageImage renders a catalog path with intrinsic dimensions and no secrets', async () => { + const { StorageImage } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup( + , + ) + const document = new window.DOMParser().parseFromString(html, 'text/html') + expect(document.querySelector('img')?.getAttribute('height')).toBe('640') + const url = document.querySelector('img')?.getAttribute('src') + expect(url).toContain('catalog-app.tlcdn.com') + expect(url).not.toMatch(/auth_key|sig=|exp=/) +}) + +test('catalog delivery overrides are used without a generated factory', async () => { + project.catalog.delivery = { + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + } + const { StorageImage } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup() + expect(html).toContain('http://127.0.0.1:32189/file/catalog-app/') + expect(html).toContain('cdn=required') +}) + +test.each([ + 'development', + 'production', +])('an authorizer added after bundling gets restart advice only in %s', async (environment) => { + vi.stubEnv('NODE_ENV', environment) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200 })), + ) + const directory = await mkdtemp(join(tmpdir(), 'img-late-authorizer-')) + project.authorizePath = join(directory, 'transloadit.authorize.ts') + try { + const { StorageImage } = await import('../src/next/react-server.tsx') + const renderPrivate = () => + renderToStaticMarkup() + expect(renderPrivate).toThrow("Private images require authorize or delivery: 'direct'") + await writeFile(project.authorizePath, 'export const authorize = () => false\n') + expect(renderPrivate).toThrow( + environment === 'development' + ? 'transloadit.authorize.ts exists but was added after next dev started. Restart next dev to bundle it.' + : "Private images require authorize or delivery: 'direct'", + ) + // The late file never changes the bundled policy or blocks already-public delivery. + const html = renderToStaticMarkup() + expect(html).toContain('builtin%2Fpublic-preview') + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('the conventional private handler checks each request and never returns image bytes', async () => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'app-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'app-secret') + const authorize = vi.fn( + ({ path, request }: { path: string; request: Request }) => + path === 'uploads/avatar.png' && request.headers.get('cookie') === 'session=allowed', + ) + project.authorize = authorize + const { StorageImage } = await import('../src/next/react-server.tsx') + const { GET, HEAD } = await import('../src/next/route.ts') + const html = renderToStaticMarkup() + const document = new window.DOMParser().parseFromString(html, 'text/html') + const src = document.querySelector('img')?.getAttribute('src') + if (src === null || src === undefined) throw new Error('Expected private image route') + const request = new Request(new URL(src, 'https://app.example')) + expect((await GET(request)).status).toBe(404) + const allowed = await HEAD(new Request(request, { headers: { cookie: 'session=allowed' } })) + expect(authorize).toHaveBeenCalledTimes(2) + expect(allowed.status).toBe(307) + expect(await allowed.text()).toBe('') + expect(parseSmartCdnUrl(allowed.headers.get('location') ?? '').input).toBe('uploads/avatar.png') + expect(html).not.toMatch(/app-key|app-secret|auth_key/) +}) + +test('a catalog reload names newly private paths once, never in production', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'app-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'app-secret') + project.authorize = () => false + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { getProjectImages } = await import('../src/next/project.ts') + getProjectImages() + expect(info).not.toHaveBeenCalled() + project.catalog.public = [] + vi.resetModules() + const refreshed = await import('../src/next/project.ts') + refreshed.getProjectImages() + refreshed.getProjectImages() + expect(info).toHaveBeenCalledExactlyOnceWith( + '[StorageImage] Catalog public prefixes changed. These paths now require the private image route and authorization: "website/hero.jpg".', + ) + const { StorageImage } = refreshed.getProjectImages() + const html = renderToStaticMarkup() + expect(html).toContain('/api/storage-images?cap=') + expect(html).not.toContain('builtin%2Fpublic-preview') + project.catalog.public = ['website/'] + vi.resetModules() + ;(await import('../src/next/project.ts')).getProjectImages() + vi.stubEnv('NODE_ENV', 'production') + project.catalog.public = [] + vi.resetModules() + ;(await import('../src/next/project.ts')).getProjectImages() + expect(info).toHaveBeenCalledOnce() +}) diff --git a/packages/img/test/model.test.ts b/packages/img/test/model.test.ts index 292769eb..8ab234a6 100644 --- a/packages/img/test/model.test.ts +++ b/packages/img/test/model.test.ts @@ -21,6 +21,180 @@ function collectSignedRequests(): { } describe('createTransloaditImageModel', () => { + test.each([ + { widths: undefined }, + { widths: [1, 100] }, + ])('explains a subpixel source crop before signing (widths $widths)', ({ widths }) => { + const { requests, sign } = collectSignedRequests() + expect(() => + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/banner.jpg', width: 1000, height: 10 }, + cropAspectRatio: 0.01, + widths, + }, + sign, + ), + ).toThrow(/source dimensions.*cropAspectRatio.*one pixel/) + expect(requests).toEqual([]) + }) + + test('accepts the one-pixel source crop boundary without upscaling', () => { + const { requests, sign } = collectSignedRequests() + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/banner.jpg', width: 1000, height: 10 }, + cropAspectRatio: 0.1, + widths: [1, 100], + }, + sign, + ) + expect(requests.map(({ urlParams }) => [urlParams.w, urlParams.h])).toEqual([ + [1, 10], + [1, 10], + [1, 10], + ]) + }) + + test.each([ + undefined, + '#224466', + '#AABBCCFF', + ])('preserves alpha per candidate and gives JPEG an opaque background (%s)', (fallbackBackground) => { + const { requests, sign } = collectSignedRequests() + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/logo.png', width: 64, height: 64 }, + formats: { avif: 45, webp: 75, png: 75 }, + fallbackBackground, + widths: [32, 64], + }, + sign, + ) + expect(requests).toHaveLength(7) + expect(requests.map(({ template }) => template)).toEqual( + Array(7).fill('builtin/storage-preview@0.0.2'), + ) + expect(requests.slice(0, -1).map(({ urlParams }) => urlParams.bg)).toEqual( + Array(6).fill('#00000000'), + ) + expect(requests.at(-1)?.urlParams).toMatchObject({ + bg: fallbackBackground ?? '#ffffff', + f: 'jpg', + }) + }) + + test.each([ + 'transparent', + '#00000000', + '#22446680', + '#fff', + '#ffffff\n', + 'ffffff', + ])('rejects an invalid or nonopaque JPEG background before signing: %j', (fallbackBackground) => { + const { requests, sign } = collectSignedRequests() + expect(() => + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/logo.png', width: 64, height: 64 }, + fallbackBackground, + }, + sign, + ), + ).toThrow(/fallbackBackground.*opaque/) + expect(requests).toEqual([]) + }) + + test('signs cover crops in the box ratio without upscaling source pixels', () => { + const { requests, sign } = collectSignedRequests() + const model = createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/hero.jpg', width: 2400, height: 1600 }, + cropAspectRatio: 9 / 16, + widths: [390, 780, 2400], + fallbackWidth: 390, + }, + sign, + ) + expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([390, 780, 900]) + expect(requests[0]?.urlParams).toMatchObject({ r: 'fillcrop', w: 390, h: 693 }) + expect(requests.at(-1)?.urlParams).toMatchObject({ r: 'fillcrop', w: 390, h: 693, f: 'jpg' }) + }) + + test('caps the default ladder at an explicit maximum without changing source proportions', () => { + const { requests, sign } = collectSignedRequests() + const model = createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/hero.jpg', width: 2400, height: 1600 }, + maximumWidth: 1920, + }, + sign, + ) + expect(model.sources[0]?.candidates.at(-1)?.width).toBe(1920) + expect(requests.at(-1)?.urlParams).toMatchObject({ r: 'pad', w: 1920, h: 1280 }) + }) + + test.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + ])('rejects invalid crop aspect ratio %s before signing', (cropAspectRatio) => { + const { requests, sign } = collectSignedRequests() + expect(() => + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/hero.jpg', width: 2400, height: 1600 }, + cropAspectRatio, + }, + sign, + ), + ).toThrow('cropAspectRatio') + expect(requests).toEqual([]) + }) + + test('caps the JPEG fallback at the largest requested candidate', () => { + const { requests, sign } = collectSignedRequests() + createTransloaditImageModel( + { + expiresAt, + src: { path: 'website/avatar.jpg', width: 400, height: 400 }, + widths: [96, 48], + }, + sign, + ) + expect(requests.at(-1)?.urlParams).toMatchObject({ f: 'jpg', w: 96, h: 96 }) + }) + + test('uses receipt geometry without forwarding ancillary receipt fields to signing', () => { + const src = { + path: 'documents/report.pdf', + width: 400, + height: 300, + asset_id: 'private-id', + authSecret: 'not-a-signing-option', + } + const fromReceipt = collectSignedRequests() + const fromString = collectSignedRequests() + const options = { expiresAt, widths: [200, 400] } + expect(createTransloaditImageModel({ ...options, src }, fromReceipt.sign)).toEqual( + createTransloaditImageModel( + { ...options, src: src.path, width: 400, height: 300 }, + fromString.sign, + ), + ) + expect(fromReceipt.requests).toEqual(fromString.requests) + expect(JSON.stringify(fromReceipt.requests)).not.toContain('private-id') + expect(JSON.stringify(fromReceipt.requests)).not.toContain('not-a-signing-option') + }) + test('builds responsive Storage previews and a signed JPEG fallback', () => { const { requests, sign } = collectSignedRequests() const model = createTransloaditImageModel( @@ -53,20 +227,20 @@ describe('createTransloaditImageModel', () => { { expiresAt, input: 'documents/report.pdf', - template: 'builtin/storage-preview@0.0.1', - urlParams: { f: 'webp', h: 150, q: 61, r: 'pad', w: 200 }, + template: 'builtin/storage-preview@0.0.2', + urlParams: { bg: '#00000000', f: 'webp', h: 150, q: 61, r: 'pad', w: 200 }, }, { expiresAt, input: 'documents/report.pdf', - template: 'builtin/storage-preview@0.0.1', - urlParams: { f: 'webp', h: 300, q: 61, r: 'pad', w: 400 }, + template: 'builtin/storage-preview@0.0.2', + urlParams: { bg: '#00000000', f: 'webp', h: 300, q: 61, r: 'pad', w: 400 }, }, { expiresAt, input: 'documents/report.pdf', - template: 'builtin/storage-preview@0.0.1', - urlParams: { f: 'jpg', h: 300, q: 68, r: 'pad', w: 400 }, + template: 'builtin/storage-preview@0.0.2', + urlParams: { bg: '#ffffff', f: 'jpg', h: 300, q: 68, r: 'pad', w: 400 }, }, ]) }) @@ -122,7 +296,14 @@ describe('createTransloaditImageModel', () => { ) expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([200, 400]) - expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 300, q: 75, r: 'pad', w: 400 }) + expect(requests.at(-1)?.urlParams).toEqual({ + bg: '#ffffff', + f: 'jpg', + h: 300, + q: 75, + r: 'pad', + w: 400, + }) }) test('rejects an invalid fallback quality before signing any candidate', () => { @@ -163,7 +344,14 @@ describe('createTransloaditImageModel', () => { [400, 400], ) expect(requests.slice(0, -1).every(({ urlParams }) => urlParams.h === 1200)).toBe(true) - expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 1200, q: 75, r: 'pad', w: 400 }) + expect(requests.at(-1)?.urlParams).toEqual({ + bg: '#ffffff', + f: 'jpg', + h: 1200, + q: 75, + r: 'pad', + w: 400, + }) }) test.each([ diff --git a/packages/img/test/next-config.test.ts b/packages/img/test/next-config.test.ts new file mode 100644 index 00000000..c06f2251 --- /dev/null +++ b/packages/img/test/next-config.test.ts @@ -0,0 +1,183 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { promisify } from 'node:util' + +import { + PHASE_DEVELOPMENT_SERVER, + PHASE_PRODUCTION_BUILD, + PHASE_PRODUCTION_SERVER, +} from 'next/constants.js' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { withTransloaditImages } from '../src/next/config.ts' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'img-next-config-')) + await writeFile( + join(root, 'transloadit.images.json'), + JSON.stringify({ workspace: 'my-app', public: ['website/'], images: {} }), + ) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +test('the documented upload factory, route and receipt page typecheck together', async () => { + const repoRoot = resolve(import.meta.dirname, '../../..') + const reference = await readFile(resolve(repoRoot, 'packages/img/docs/reference.md'), 'utf8') + const uploads = reference.slice( + reference.indexOf('### Images uploaded by your users'), + reference.indexOf('### Credentials and framework adapters'), + ) + // This package's test command builds its declarations; root script tests run before that build. + await symlink(resolve(repoRoot, 'node_modules'), join(root, 'node_modules'), 'dir') + const files: string[] = [] + for (const block of uploads.split('```')) { + const match = block.match(/^tsx?\n\/\/ (app\/[^\n]+)\n([\s\S]*)$/) + if (match?.[1] === undefined || match[2] === undefined) continue + const file = join(root, match[1]) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, match[2]) + files.push(file) + } + expect(files).toHaveLength(3) + await mkdir(join(root, 'lib')) + // Only application-owned helpers are declared; SDK/framework imports use real declarations. + await writeFile( + join(root, 'lib/authorization.ts'), + 'export declare function getSession(request: Request): Promise<{ canRead(path: string): boolean } | null>\n', + ) + await writeFile( + join(root, 'lib/images.ts'), + "import type { TransloaditImageSource } from '@transloadit/img'\nexport declare function getAuthorizedImage(id: string): Promise\n", + ) + const result = await promisify(execFile)( + process.execPath, + [ + resolve(repoRoot, 'node_modules/typescript/bin/tsc'), + '--ignoreConfig', + '--noEmit', + '--strict', + // Check the recipe without rechecking dependency internals. + '--skipLibCheck', + '--target', + 'es2022', + '--module', + 'esnext', + '--moduleResolution', + 'bundler', + '--jsx', + 'react-jsx', + '--esModuleInterop', + '--types', + 'node,react', + ...files, + ], + { cwd: root, timeout: 25_000 }, + ) + expect(result.stdout).toBe('') +}, 30_000) + +test('only development carries a stable catalog identity for hot-reload diagnostics', async () => { + const plugin = withTransloaditImages({}, { root }) + const path = join(root, 'node_modules/.cache/transloadit-images/options.json') + plugin(PHASE_DEVELOPMENT_SERVER) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ + authorizePath: join(root, 'transloadit.authorize.ts'), + diagnosticsId: join(root, 'transloadit.images.json'), + }) + plugin(PHASE_PRODUCTION_BUILD) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({}) +}) + +test('binds the conventional catalog and retains unrelated Next configuration', async () => { + const config = withTransloaditImages( + { + basePath: '/site', + outputFileTracingIncludes: { '/*': ['./existing.json'], '/other': ['./other.json'] }, + turbopack: { resolveAlias: { existing: './existing.ts' } }, + }, + { root }, + )(PHASE_PRODUCTION_BUILD) + expect(config.basePath).toBe('/site') + expect(config.turbopack?.resolveAlias).toMatchObject({ + existing: './existing.ts', + '@transloadit/img/next/catalog': './transloadit.images.json', + }) + expect(config.outputFileTracingIncludes).toMatchObject({ + '/*': expect.arrayContaining(['./existing.json', './transloadit.images.json']), + '/other': ['./other.json'], + }) + expect( + JSON.parse( + await readFile(join(root, 'node_modules/.cache/transloadit-images/options.json'), 'utf8'), + ), + ).toEqual({ basePath: '/site' }) +}) + +test('discovers authorization and allows a custom catalog and delivery without touching the source', async () => { + await mkdir(join(root, 'assets')) + await writeFile(join(root, 'assets/images.json'), '{}') + await writeFile(join(root, 'transloadit.authorize.ts'), 'export const authorize = () => false\n') + const delivery = { + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + } + const config = withTransloaditImages( + {}, + { root, catalog: 'assets/images.json', delivery }, + )(PHASE_PRODUCTION_BUILD) + expect(config.turbopack?.resolveAlias).toMatchObject({ + '@transloadit/img/next/catalog': './assets/images.json', + '@transloadit/img/next/authorize': './transloadit.authorize.ts', + }) + expect( + JSON.parse( + await readFile(join(root, 'node_modules/.cache/transloadit-images/options.json'), 'utf8'), + ), + ).toEqual({ delivery }) + expect(await readFile(join(root, 'assets/images.json'), 'utf8')).toBe('{}') +}) + +test('missing catalog names the upload command, not image init', async () => { + await rm(join(root, 'transloadit.images.json')) + expect(() => withTransloaditImages({}, { root })(PHASE_PRODUCTION_BUILD)).toThrow(/storage store/) +}) + +test('production start requires neither a source catalog nor regenerating a pruned cache', async () => { + await rm(join(root, 'transloadit.images.json')) + const plugin = withTransloaditImages({ basePath: '/site' }, { root }) + const config = plugin(PHASE_PRODUCTION_SERVER) + expect(config.basePath).toBe('/site') + await expect( + readFile(join(root, 'node_modules/.cache/transloadit-images/options.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('the webpack adapter applies exact aliases after preserving the application hook', () => { + const upstream = vi.fn(() => ({ resolve: { alias: { other: '/app/other.ts' } }, retained: true })) + const config = withTransloaditImages({ webpack: upstream }, { root })(PHASE_PRODUCTION_BUILD) + if (typeof config.webpack !== 'function') throw new Error('Expected the bundler hook') + const input = { name: 'server' } + const context = { isServer: true } + const output = Reflect.apply(config.webpack, undefined, [input, context]) + expect(upstream).toHaveBeenCalledExactlyOnceWith(input, context) + expect(output).toMatchObject({ + retained: true, + resolve: { + alias: { + other: '/app/other.ts', + '@transloadit/img/next/catalog$': join(root, 'transloadit.images.json'), + }, + }, + }) +}) + +test('declines catalog paths outside the app root instead of silently depending on untraced files', () => { + expect(() => + withTransloaditImages({}, { root, catalog: '../outside.json' })(PHASE_PRODUCTION_BUILD), + ).toThrow(/inside the Next.js app/) +}) diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx index 29c3686c..db5ba152 100644 --- a/packages/img/test/next-server.test.tsx +++ b/packages/img/test/next-server.test.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from 'react' +import type { StorageImagesConfiguration } from '../src/next/server.tsx' + import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { act } from 'react' +import { createRoot } from 'react-dom/client' import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -11,16 +15,18 @@ const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefin vi.mock('next/server.js', () => ({ connection })) vi.mock('server-only', () => ({})) -import { createTransloaditImage } from '../src/next/server.tsx' +import { createImageDiagnostics } from '../src/next/diagnostics.ts' +import { createStorageImages } from '../src/next/server.tsx' const authSecret = 'never-render-this-secret' const baseConfiguration = { + delivery: 'direct', authKey: 'auth-key', authSecret, baseUrl: 'https://cdn.example/file/{workspace}', - storage: { allowedPathPrefixes: ['documents/'] }, + allowedPathPrefixes: ['documents/'], workspace: 'my-app', -} +} satisfies StorageImagesConfiguration async function renderAsync(node: ReactNode): Promise { const stream = await renderToReadableStream(node) @@ -49,12 +55,11 @@ function getStorageRouteCandidate(): { ({ path, request }: { path: string; request: Request }): boolean => path === 'documents/report.pdf' && request.headers.get('authorization') === 'Bearer allowed', ) - const { Image, storageRoute } = createTransloaditImage({ + const { StorageImage: Image, storageRoute } = createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize, route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize, + route: '/api/private-images', }) const markup = renderToStaticMarkup( { vi.useRealTimers() }) -describe('createTransloaditImage', () => { +describe('development delivery diagnostics', () => { + test.each([ + 'HTTP failure', + 'network failure', + ])('gives actionable %s advice without requiring a development restart', async (failure) => { + if (failure === 'HTTP failure') { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 400 })) + } else { + vi.mocked(fetch).mockRejectedValue(new Error(`Could not fetch ${authSecret}`)) + } + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + baseUrl: 'https://cdn.example:8443/file/{workspace}', + urlParams: { token: 'never-log-query-token' }, + }) + const markup = await renderAsync( + , + ) + const target = new URL(getFirstCandidate(parseMarkup(markup))) + expect(fetch).toHaveBeenCalledWith(target.href, expect.objectContaining({ method: 'HEAD' })) + expect(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining(`${target.origin}${target.pathname}`), + ) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('baseUrl/urlParams')) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Check')) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch( + /restart|never-render-this-secret|never-log-query-token|auth-key|\?|sig=|exp=/, + ) + }) + + test('does not call a redirecting origin a failed image delivery', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(null, { status: 307, headers: { Location: 'https://cdn.example/image' } }), + ) + const { StorageImage } = createStorageImages(baseConfiguration) + await renderAsync( + , + ) + expect(fetch).toHaveBeenCalledOnce() + expect(console.warn).not.toHaveBeenCalled() + }) + test('explains denied route boundaries with static, deduplicated reasons only', async () => { + const { storageRoute, url } = getStorageRouteCandidate() + const wrongRoute = new URL(url) + wrongRoute.pathname = '/wrong-route' + expect((await storageRoute(new Request(wrongRoute))).status).toBe(404) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('route/basePath')) + const malformed = new URL(url) + malformed.searchParams.set('cap', 'secret-sentinel') + expect((await storageRoute(new Request(malformed))).status).toBe(404) + expect((await storageRoute(new Request(malformed))).status).toBe(404) + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/secret.*template/)) + expect(console.warn).toHaveBeenCalledTimes(2) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('secret-sentinel') + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(url.href) + expect(fetch).not.toHaveBeenCalled() + }) + + test('explains direct rendering once per integration, without logging credentials or URLs', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const props = { alt: 'Hero', src: { path: 'documents/hero.jpg', width: 400, height: 300 } } + await renderAsync() + await renderAsync() + expect(console.info).toHaveBeenCalledExactlyOnceWith( + 'StorageImage (direct) makes this route dynamic; use redirect delivery for static pages', + ) + }) + + test.each([ + 'GET', + 'HEAD', + ])('a denied private %s route names its path and the opt-in publication fix', async (method) => { + const { StorageImage, storageRoute } = createStorageImages({ + ...baseConfiguration, + delivery: undefined, + public: [], + authorize: () => false, + }) + const markup = renderToStaticMarkup( + , + ) + const url = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') + const denied = await storageRoute(new Request(url, { method })) + expect(denied.status).toBe(404) + expect(await denied.text()).toBe('') + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('"documents/hero.jpg"')) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not under a public prefix')) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('If it should be public')) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('current image configuration'), + ) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('--receipts ')) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('explicit factory’s public list'), + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/npx transloadit storage publish -- documents\/$/), + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('storage publish -- documents/'), + ) + await storageRoute(new Request(url)) + expect(console.warn).toHaveBeenCalledOnce() + expect(fetch).not.toHaveBeenCalled() + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch( + /cap=|auth-key|never-render-this-secret/, + ) + }) + + test('an inline image fallback keeps development advice valid inside a paragraph', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(false) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { StorageImage } = createStorageImages({ ...baseConfiguration, authorize: () => false }) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + try { + await act(() => + root.render( +

+ Image unavailable. } + /> +

, + ), + ) + const image = container.querySelector('img') + expect(image).not.toBeNull() + await act(() => image?.dispatchEvent(new Event('error'))) + expect(container.querySelector('[role="status"]')?.textContent).toBe('Image unavailable. ') + expect(container.textContent).toContain('See the terminal for details.') + expect(errors).not.toHaveBeenCalled() + } finally { + await act(() => root.unmount()) + container.remove() + } + }) + + test('direct images settle before a slow diagnostic, without consuming their grant lifetime', async () => { + vi.useRealTimers() + let finishProbe: (response: Response) => void = () => { + throw new Error('Expected the pending probe') + } + const probe = new Promise((resolve) => { + finishProbe = resolve + }) + vi.mocked(fetch).mockReturnValue(probe) + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + lifetime: 5000, + rotationIntervalMs: 1000, + }) + const rendered = renderAsync( + , + ) + try { + await vi.waitFor(async () => { + const result = await Promise.race([rendered, Promise.resolve('pending')]) + expect(result).toContain('') + }) + expect(fetch).toHaveBeenCalledOnce() + const document = parseMarkup(await rendered) + expect(Number(new URL(getFirstCandidate(document)).searchParams.get('exp'))).toBeGreaterThan( + Date.now(), + ) + } finally { + finishProbe(new Response(null, { headers: { 'Content-Type': 'image/jpeg' } })) + await rendered + } + }) + + test('redirects settle before a slow diagnostic and consume its rejection safely', async () => { + vi.useRealTimers() + let failProbe: (error: Error) => void = () => { + throw new Error('Expected the pending probe') + } + const probe = new Promise((_resolve, reject) => { + failProbe = reject + }) + vi.mocked(fetch).mockReturnValue(probe) + const { storageRoute, url } = getStorageRouteCandidate() + const response = storageRoute( + new Request(url, { headers: { Authorization: 'Bearer allowed' } }), + ) + try { + await vi.waitFor(async () => { + const result = await Promise.race([response, Promise.resolve(undefined)]) + expect(result?.status).toBe(307) + }) + expect(fetch).toHaveBeenCalledOnce() + expect(console.warn).not.toHaveBeenCalled() + } finally { + failProbe(new Error(`Failed at ${authSecret}`)) + await response + } + await vi.waitFor(() => expect(console.warn).toHaveBeenCalledOnce()) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(authSecret) + }) + + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { headers: { 'Content-Type': 'image/jpeg' } })), + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'info').mockImplementation(() => {}) + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + test('checks one HEAD per path/template per configured integration', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const props = { alt: 'Preview', src: { path: 'documents/hero.jpg', width: 400, height: 300 } } + await Promise.all([ + renderAsync(), + renderAsync(), + ]) + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'HEAD', + redirect: 'manual', + cache: 'no-store', + signal: expect.any(AbortSignal), + }), + ) + expect(console.warn).not.toHaveBeenCalled() + const other = createStorageImages({ ...baseConfiguration, template: 'another-preview' }) + await renderAsync() + expect(fetch).toHaveBeenCalledTimes(2) + }) + + test('shares the bounded HEAD result with the development failure UI, without query credentials', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 403 })) + const diagnose = createImageDiagnostics('test-template') + const result = diagnose?.('website/hero.jpg', 'https://cdn.example/hero.jpg?sig=private-value') + expect(await result).toBe('HEAD https://cdn.example/hero.jpg: HTTP 403') + expect(diagnose?.('website/hero.jpg', 'https://cdn.example/hero.jpg?sig=rotated')).toBe(result) + expect(fetch).toHaveBeenCalledOnce() + }) + + test('never probes from production rendering', async () => { + vi.stubEnv('NODE_ENV', 'production') + const { StorageImage } = createStorageImages(baseConfiguration) + await renderAsync( + , + ) + expect(fetch).not.toHaveBeenCalled() + expect(console.warn).not.toHaveBeenCalled() + }) + + test('diagnoses the least-privilege signing scope from the origin header without reading its body', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(authSecret, { + status: 403, + headers: { 'Transloadit-Error': 'INSUFFICIENT_AUTH_SCOPE' }, + }), + ) + const diagnose = createImageDiagnostics('scope-test') + expect( + await diagnose?.('uploads/hero.jpg', 'https://cdn.example/hero.jpg?sig=private'), + ).toContain('INSUFFICIENT_AUTH_SCOPE') + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/INSUFFICIENT_AUTH_SCOPE.*smart_cdn:sign.*Console.*Credentials/), + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('assemblies:write is also accepted'), + ) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch( + /server clock|never-render-this-secret|sig=private/, + ) + expect(fetch).toHaveBeenCalledExactlyOnceWith( + expect.any(String), + expect.objectContaining({ method: 'HEAD' }), + ) + }) + + test.each([ + { code: 'INVALID_SIGNATURE', status: 403 }, + { code: 'AUTH_KEY_NOT_FOUND', status: 401 }, + { code: 'INTERNAL_SERVER_ERROR', status: 500 }, + ])('retains origin error code $code in the safe HEAD result and diagnostic', async ({ + code, + status, + }) => { + vi.mocked(fetch).mockResolvedValue( + new Response(null, { status, headers: { 'Transloadit-Error': code } }), + ) + const diagnose = createImageDiagnostics('code-test') + expect(await diagnose?.('uploads/hero.jpg', 'https://cdn.example/hero.jpg')).toContain(code) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(code)) + }) + + test('does not echo malformed origin error metadata or a response body', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(authSecret, { + status: 403, + headers: { 'Transloadit-Error': `INSUFFICIENT_AUTH_SCOPE?secret=${authSecret}` }, + }), + ) + const diagnose = createImageDiagnostics('unsafe-code-test') + const result = await diagnose?.('uploads/hero.jpg', 'https://cdn.example/hero.jpg') + expect(result).not.toContain(authSecret) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(authSecret) + }) + + test.each([ + { status: 403, hint: /Enable Smart CDN.*Auth Key.*workspace.*signature/ }, + { status: 404, hint: /workspace slug.*Storage path.*Template/ }, + { status: 500, hint: /HTTP 500.*Check the delivery endpoint and Template/ }, + ])('gives actionable, non-secret hints for HTTP $status without guessing the cause', async ({ + status, + hint, + }) => { + vi.mocked(fetch).mockResolvedValue( + new Response(authSecret, { status, headers: { 'x-secret': authSecret } }), + ) + const { StorageImage } = createStorageImages(baseConfiguration) + const props = { alt: 'Preview', src: { path: 'documents/hero.jpg', width: 400, height: 300 } } + await renderAsync() + await renderAsync() + expect(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(hint)) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(authSecret) + expect(fetch).toHaveBeenCalledOnce() + }) + + test('sanitizes network failures while preserving native rendering', async () => { + vi.mocked(fetch).mockRejectedValue(new Error(`Failed at secret URL ${authSecret}`)) + const { StorageImage } = createStorageImages(baseConfiguration) + const markup = await renderAsync( + , + ) + expect(markup).toContain('') + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('reach Smart CDN')) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(authSecret) + }) + + test('never diagnoses an unauthorized redirect or a path outside policy', async () => { + const authorize = vi.fn(() => false) + const { StorageImage, storageRoute } = createStorageImages({ + ...baseConfiguration, + route: '/images', + authorize, + }) + expect(() => + StorageImage({ alt: 'Denied', src: { path: 'private/hero.jpg', width: 400, height: 300 } }), + ).toThrow(/allowed prefixes/) + const document = parseMarkup( + renderToStaticMarkup( + , + ), + ) + const request = new Request(new URL(getFirstCandidate(document), 'https://app.example')) + expect((await storageRoute(request)).status).toBe(404) + expect(fetch).not.toHaveBeenCalled() + authorize.mockReturnValue(true) + expect((await storageRoute(request)).status).toBe(307) + expect(fetch).toHaveBeenCalledOnce() + }) +}) + +describe('createStorageImages', () => { + test.each([ + 'direct', + 'redirect', + ])('binds transparent candidates and the configured JPEG background through %s delivery', async (delivery) => { + const integration = + delivery === 'direct' + ? createStorageImages(baseConfiguration) + : createStorageImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + route: '/images', + authorize: () => true, + }) + const document = parseMarkup( + await renderAsync( + , + ), + ) + const candidate = getFirstCandidate(document) + const fallback = document.querySelector('img')?.getAttribute('src') + if (fallback == null) throw new Error('Expected a JPEG fallback') + for (const [source, bg] of [ + [candidate, '#00000000'], + [fallback, '#224466'], + ]) { + if (source === undefined) throw new Error('Expected a candidate URL') + const location = + 'storageRoute' in integration + ? ( + await integration.storageRoute(new Request(new URL(source, 'https://app.example'))) + ).headers.get('location') + : source + if (location === null) throw new Error('Expected a redirect') + expect( + parseSmartCdnUrl(location, { + baseUrl: baseConfiguration.baseUrl, + workspace: baseConfiguration.workspace, + }).urlParams.bg, + ).toBe(bg) + expect(new URL(location).hash).toBe('') + expect(location).toContain('bg=%23') + } + }) + + test('rejects a global background override before it can flatten alpha or make JPEG transparent', () => { + expect(() => + createStorageImages({ + ...baseConfiguration, + urlParams: { bg: '#00000000' }, + }), + ).toThrow(/image policy parameter: bg/) + }) + + test('layout defaults leave explicit sizes, widths and styles in control', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + , + ), + ) + expect(document.querySelector('source')?.sizes).toBe('50vw') + expect(document.querySelector('source')?.srcset).toContain('2400w') + expect(document.querySelector('img')?.style.maxWidth).toBe('1200px') + }) + + test.each<'fixed' | 'fill'>([ + 'fixed', + 'fill', + ])('explains that %s layout needs receipt geometry rather than a string source', (layout) => { + const { StorageImage } = createStorageImages(baseConfiguration) + expect(() => + // @ts-expect-error JavaScript callers can pass a string where fixed layout requires a receipt. + StorageImage({ + alt: 'Avatar', + src: 'documents/avatar.jpg', + layout, + width: 48, + height: 48, + }), + ).toThrow(`${layout} layout requires a receipt source`) + expect(connection).not.toHaveBeenCalled() + }) + + test.each([ + { layout: 'fixed', width: 0, height: 48 }, + { layout: 'constrained', width: Number.NaN }, + { layout: 'fill', fit: 'cover' }, + { layout: 'fill', fit: 'cover', aspectRatio: '9/0' }, + { layout: 'fixed', width: 48, height: 48, fit: 'stretch' }, + { layout: 'other' }, + ])('rejects invalid layout before rendering %j', (layout) => { + const { StorageImage } = createStorageImages(baseConfiguration) + expect(() => + Reflect.apply(StorageImage, undefined, [ + { + alt: 'Invalid', + src: { path: 'documents/hero.jpg', width: 400, height: 300 }, + ...layout, + }, + ]), + ).toThrow() + expect(connection).not.toHaveBeenCalled() + }) + + test('derives constrained layout and caps its candidates at twice maxWidth', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + , + ), + ) + const image = document.querySelector('img') + expect(image?.style.cssText).toBe( + 'display: block; max-width: 960px; width: 100%; height: auto;', + ) + expect(image?.getAttribute('width')).toBe('960') + const source = document.querySelector('source') + expect(source?.sizes).toBe('auto, (min-width: 960px) 960px, 100vw') + expect(source?.srcset).toContain('1920w') + expect(source?.srcset).not.toContain('2400w') + }) + + test.each([ + 160, 320, 960, + ])('constrained maxWidth %d never enlarges a 320px original', async (maxWidth) => { + const { StorageImage } = createStorageImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + , + ), + ) + const limit = Math.min(320, maxWidth) + expect(document.querySelector('img')?.style.maxWidth).toBe(`${limit}px`) + expect(document.querySelector('source')?.sizes).toBe( + `(min-width: ${limit}px) ${limit}px, 100vw`, + ) + expect(document.querySelector('source')?.srcset).toContain('320w') + expect(document.querySelector('source')?.srcset).not.toContain('640w') + }) + + test('fixed cover uses receipt geometry for a signed 48px crop and a 1x JPEG fallback', async () => { + const { StorageImage, storageRoute } = createStorageImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + }) + const document = parseMarkup( + renderToStaticMarkup( + , + ), + ) + const image = document.querySelector('img') + expect(image?.getAttribute('width')).toBe('48') + expect(image?.getAttribute('height')).toBe('48') + expect(document.querySelector('source')?.sizes).toBe('48px') + expect(document.querySelector('source')?.srcset).toContain('96w') + expect(document.querySelector('source')?.srcset).not.toContain('400w') + const response = await storageRoute( + new Request(new URL(image?.getAttribute('src') ?? '', 'https://app.example')), + ) + const target = parseSmartCdnUrl(response.headers.get('location') ?? '', { + baseUrl: baseConfiguration.baseUrl, + }) + expect(target.urlParams).toEqual({ r: 'fillcrop', w: '48', h: '48' }) + }) + + test('fill cover signs the declared box ratio and retains explicit layout overrides', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + , + ), + ) + expect(document.querySelector('img')?.style.position).toBe('relative') + expect(document.querySelector('img')?.style.width).toBe('100%') + expect(document.querySelector('source')?.sizes).toBe('100vw') + const target = parseSmartCdnUrl(getFirstCandidate(document), { + baseUrl: baseConfiguration.baseUrl, + }) + expect(target.urlParams).toMatchObject({ r: 'fillcrop', w: '390', h: '693' }) + }) + + test('exports an unambiguous StorageImage component', () => { + const integration = createStorageImages(baseConfiguration) + expect(integration.StorageImage).toBeTypeOf('function') + expect(Object.keys(integration)).toEqual(['StorageImage']) + }) + + beforeEach(() => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', baseConfiguration.authKey) + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', baseConfiguration.authSecret) + vi.stubEnv('TRANSLOADIT_WORKSPACE', baseConfiguration.workspace) + }) + + afterEach(() => vi.unstubAllEnvs()) + + test('snapshots rendering environment on first use and delegates to the explicit factory', async () => { + const { StorageImage: Image } = createStorageImages({ + baseUrl: baseConfiguration.baseUrl, + allowedPathPrefixes: baseConfiguration.allowedPathPrefixes, + delivery: 'direct', + }) + const { StorageImage: ExplicitImage } = createStorageImages(baseConfiguration) + const props = { + alt: 'Snapshot', + src: { path: 'documents/report.pdf', width: 400, height: 300 }, + } + await renderAsync(Image(props)) + await renderAsync(ExplicitImage(props)) + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'changed-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'changed-secret') + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'changed-workspace') + const actual = parseMarkup(await renderAsync(Image(props))) + const expected = parseMarkup(await renderAsync(ExplicitImage(props))) + expect(actual.querySelector('picture')?.isEqualNode(expected.querySelector('picture'))).toBe( + true, + ) + expect(actual.documentElement.outerHTML).not.toContain(authSecret) + expect(actual.documentElement.outerHTML).not.toContain('changed-') + }) + + test.each( + ['TRANSLOADIT_SMART_CDN_KEY', 'TRANSLOADIT_SMART_CDN_SECRET', 'TRANSLOADIT_WORKSPACE'].flatMap( + (name) => + [undefined, '', ' ', ' secret-with-whitespace '].map((value) => ({ name, value })), + ), + )('rejects missing or invalid $name without exposing its value', ({ name, value }) => { + vi.stubEnv(name, value) + const { StorageImage } = createStorageImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + }) + expect(() => + StorageImage({ src: 'documents/test.png', alt: 'Test', width: 10, height: 10 }), + ).toThrowError( + new TypeError(`${name} must be a non-empty string without surrounding whitespace`), + ) + }) + + test('does not read undocumented Assembly variable names or change the explicit factory', () => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', undefined) + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', undefined) + vi.stubEnv('TRANSLOADIT_ASSEMBLY_KEY', 'write-key') + vi.stubEnv('TRANSLOADIT_ASSEMBLY_SECRET', 'write-secret') + vi.stubEnv('TRANSLOADIT_KEY', undefined) + vi.stubEnv('TRANSLOADIT_SECRET', undefined) + const { StorageImage } = createStorageImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + }) + expect(() => + StorageImage({ src: 'documents/test.png', alt: 'Test', width: 10, height: 10 }), + ).toThrow( + 'Private images need a signing key. Set TRANSLOADIT_SMART_CDN_KEY and TRANSLOADIT_SMART_CDN_SECRET (Console → Credentials → New Auth Key → “Private image delivery”). TRANSLOADIT_KEY/SECRET are also accepted.', + ) + expect(() => createStorageImages(baseConfiguration)).not.toThrow() + }) + + test('still requires explicit scope and retains deny-all without path prefixes', () => { + expect(() => Reflect.apply(createStorageImages, undefined, [{}])).toThrow(/allowedPathPrefixes/) + const { StorageImage: Image } = createStorageImages({ + allowedPathPrefixes: [], + delivery: 'direct', + }) + expect(() => + Image({ alt: 'Denied', src: { path: 'documents/report.pdf', width: 400, height: 300 } }), + ).toThrow('outside the configured allowed prefixes') + expect(connection).not.toHaveBeenCalled() + }) + + test.each([ + { cacheMaxAgeMs: 10_999, expected: 'private, max-age=10' }, + { cacheMaxAgeMs: 999, expected: 'private, no-store' }, + { cacheMaxAgeMs: 120_000, expected: 'private, max-age=30' }, + ])('bounds opt-in redirect caching ($cacheMaxAgeMs ms)', async ({ cacheMaxAgeMs, expected }) => { + const delivery = { authorize: vi.fn(() => true), cacheMaxAgeMs, route: '/images' } + const { StorageImage: Image, storageRoute } = createStorageImages({ + ...baseConfiguration, + ...delivery, + rotationIntervalMs: 30_000, + }) + delivery.cacheMaxAgeMs = 1 + const document = parseMarkup( + renderToStaticMarkup( + Report, + ), + ) + const request = new Request(new URL(getFirstCandidate(document), 'https://app.example')) + const response = await storageRoute(request) + expect(response.status).toBe(307) + expect(response.headers.get('Cache-Control')).toBe(expected) + delivery.authorize.mockReturnValue(false) + const denied = await storageRoute(request) + expect(denied.status).toBe(404) + expect(denied.headers.get('Cache-Control')).toBe('private, no-store') + }) + + test.each([ + -1, + 0, + Number.NaN, + Number.POSITIVE_INFINITY, + 1.5, + ])('rejects invalid redirect cache duration %s', (cacheMaxAgeMs) => { + expect(() => + createStorageImages({ + ...baseConfiguration, + authorize: () => true, + cacheMaxAgeMs, + route: '/images', + }), + ).toThrow('cacheMaxAge must be a positive safe integer') + }) + + test.each( + [undefined, null, false, 'documents/', []].map((configuration) => ({ configuration })), + )('rejects invalid configuration $configuration', ({ configuration }) => { + expect(() => Reflect.apply(createStorageImages, undefined, [configuration])).toThrowError( + new TypeError('Storage images require an explicit configuration object'), + ) + }) + + test('retains trusted template, transport and authorization settings for redirect delivery', async () => { + const authorize = vi.fn(() => true) + const { StorageImage: Image, storageRoute } = createStorageImages({ + baseUrl: baseConfiguration.baseUrl, + template: 'website/preview', + urlParams: { cdn: 'required' }, + allowedPathPrefixes: ['documents/'], + route: '/images', + basePath: '/app', + authorize, + }) + const document = parseMarkup( + renderToStaticMarkup( + Image({ alt: 'Report', src: { path: 'documents/report.pdf', width: 400, height: 300 } }), + ), + ) + const url = new URL(getFirstCandidate(document), 'https://app.example') + expect(url.pathname).toBe('/app/images') + expect(connection).not.toHaveBeenCalled() + const response = await storageRoute(new Request(url)) + expect(response.status).toBe(307) + expect(authorize).toHaveBeenCalledOnce() + const location = response.headers.get('location') + if (location === null) throw new Error('Expected an authorized CDN target') + const candidate = parseSmartCdnUrl(location, baseConfiguration) + expect(candidate.template).toBe('website/preview') + expect(candidate.urlParams.cdn).toBe('required') + expect(candidate.input).toBe('documents/report.pdf') + }) +}) + +describe('createStorageImages', () => { + test.each([ + { + sourceWidth: 12000, + sourceHeight: 8000, + height: undefined, + width: 12000, + renderedHeight: 8000, + candidateLimit: 4096, + }, + { + sourceWidth: 2400, + sourceHeight: 1600, + height: 320, + width: 480, + renderedHeight: 320, + candidateLimit: 960, + }, + ])('constrains a $sourceWidth × $sourceHeight receipt with height $height', ({ + sourceWidth, + sourceHeight, + height, + width, + renderedHeight, + candidateLimit, + }) => { + const { StorageImage } = createStorageImages({ + workspace: 'my-app', + public: ['documents/'], + allowedPathPrefixes: ['documents/'], + }) + const document = parseMarkup( + renderToStaticMarkup( + , + ), + ) + expect(document.querySelector('img')?.getAttribute('width')).toBe(String(width)) + expect(document.querySelector('img')?.getAttribute('height')).toBe(String(renderedHeight)) + expect(document.querySelector('img')?.style.maxWidth).toBe(`${width}px`) + const candidates = document.querySelector('source')?.getAttribute('srcset')?.split(', ') + expect(candidates?.at(-1)).toMatch(new RegExp(` ${candidateLimit}w$`)) + }) + + test('ignores Next internals and uses only the explicit basePath', () => { + vi.stubEnv('__NEXT_ROUTER_BASEPATH', '/inferred') + try { + const { url } = getStorageRouteCandidate() + expect(url.pathname).toBe('/api/private-images') + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + basePath: '/explicit', + }) + expect( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + , + ), + ), + ), + ).toMatch(/^\/explicit\/images\?/) + } finally { + vi.unstubAllEnvs() + } + }) + + test('renders real per-breakpoint crops with a JPEG fallback for each art direction', () => { + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + }) + const document = parseMarkup( + renderToStaticMarkup( + , + ), + ) + expect(document.querySelectorAll('source[media="(max-width: 639px)"]')).toHaveLength(3) + expect(document.querySelectorAll('source:not([media])')).toHaveLength(2) + expect(document.querySelector('source[media][type="image/jpeg"]')).not.toBeNull() + const preloads = document.querySelectorAll('link[rel="preload"]') + expect(preloads).toHaveLength(2) + expect(document.querySelector('picture link')).toBeNull() + expect(preloads[0]?.getAttribute('media')).toBe('(max-width: 639px)') + expect(preloads[1]?.getAttribute('media')).toBe('not ((max-width: 639px))') + }) + + test.each<['lazy' | 'eager', string]>([ + ['lazy', 'auto, (min-width: 960px) 960px, 100vw'], + ['eager', '(min-width: 960px) 960px, 100vw'], + ])('derives %s constrained sizes without overestimating narrow columns', (loading, sizes) => { + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + }) + const document = parseMarkup( + renderToStaticMarkup( + , + ), + ) + expect(document.querySelector('source')?.getAttribute('sizes')).toBe(sizes) + }) + + test('layout none keeps intrinsic signing geometry while explicit dimensions describe presentation', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + , + ), + ) + expect(document.querySelector('img')?.getAttribute('width')).toBe('480') + expect(document.querySelector('img')?.getAttribute('height')).toBe('320') + const fallback = document.querySelector('img')?.getAttribute('src') + if (!fallback) throw new Error('Expected fallback') + expect(new URL(fallback).searchParams.get('w')).toBe('2400') + }) + + test('old capabilities redirect to unsigned public delivery only within the currently declared prefix', async () => { + const authorize = vi.fn(() => false) + const delivery = { authorize, public: ['documents/public/'], route: '/images' } + const { storageRoute } = createStorageImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + ...delivery, + }) + // Existing private markup remains usable when its directory is deliberately published. + const { StorageImage } = createStorageImages({ + ...baseConfiguration, + authorize, + route: '/images', + }) + delivery.public.push('documents/private/') + const candidate = (path: string): Request => + new Request( + new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + , + ), + ), + ), + 'https://app.example', + ), + ) + const response = await storageRoute(candidate('documents/public/hero.jpg')) + expect(response.status).toBe(307) + expect(response.headers.get('cache-control')).toBe('public, max-age=0, s-maxage=60') + expect(authorize).not.toHaveBeenCalled() + const denied = await storageRoute(candidate('documents/private/hero.jpg')) + expect(denied.status).toBe(404) + expect(denied.headers.get('cache-control')).toBe('private, no-store') + expect((await storageRoute(candidate('documents/publicity/hero.jpg'))).status).toBe(404) + expect(authorize).toHaveBeenCalledTimes(2) + vi.setSystemTime('2029-01-01T12:59:59.000Z') + expect( + (await storageRoute(candidate('documents/public/hero.jpg'))).headers.get('cache-control'), + ).toBe('public, max-age=0, s-maxage=60') + const refreshed = createStorageImages({ + ...baseConfiguration, + authorize, + route: '/images', + public: ['documents/public/'], + images: { + 'documents/public/hero.jpg': { + path: 'documents/public/hero.jpg', + width: 400, + height: 300, + md5hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }, + }) + const current = await refreshed.storageRoute(candidate('documents/public/hero.jpg')) + expect(current.headers.get('location')).toContain('v=bbbbbbbbbbbbbbbb') + expect(current.headers.get('cache-control')).toBe('public, max-age=0, s-maxage=60') + }) + + test('rejects public prefixes outside the signing policy', () => { + expect(() => + createStorageImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + route: '/images', + authorize: () => true, + public: ['documents'], + }), + ).toThrow('public[0]') + expect(() => + createStorageImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + public: ['other/'], + }), + ).toThrow(/public.*allowedPathPrefixes/) + }) + + test('binds explicit custom templates, still authorizes, and revokes on key rotation', async () => { + const authorize = vi.fn(() => true) + const storage = { ...baseConfiguration, route: '/images', authorize } + const old = createStorageImages({ + ...baseConfiguration, + template: 'my-custom-preview', + ...storage, + }) + const url = new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + , + ), + ), + ), + 'https://app.example', + ) + const current = createStorageImages({ + ...baseConfiguration, + ...storage, + template: 'my-custom-preview', + }) + const response = await current.storageRoute(new Request(url)) + expect(response.status).toBe(307) + expect(decodeURIComponent(response.headers.get('location') ?? '')).toContain( + 'my-custom-preview', + ) + authorize.mockReturnValue(false) + expect((await current.storageRoute(new Request(url))).status).toBe(404) + authorize.mockReturnValue(true) + const revoked = createStorageImages({ + ...baseConfiguration, + ...storage, + template: 'another-custom-preview', + }) + expect((await revoked.storageRoute(new Request(url))).status).toBe(404) + const rotated = createStorageImages({ + ...baseConfiguration, + ...storage, + authSecret: 'rotated', + template: 'my-custom-preview', + }) + expect((await rotated.storageRoute(new Request(url))).status).toBe(404) + vi.setSystemTime(Date.now() + 365 * 86_400_000) + expect((await current.storageRoute(new Request(url))).status).toBe(307) + const restarted = createStorageImages({ + ...baseConfiguration, + ...storage, + template: 'my-custom-preview', + }) + expect((await restarted.storageRoute(new Request(url))).status).toBe(307) + }) + + test('the named private factory uses rendering env, redirects and a single lifetime knob', async () => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', baseConfiguration.authKey) + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', baseConfiguration.authSecret) + vi.stubEnv('TRANSLOADIT_WORKSPACE', baseConfiguration.workspace) + try { + const { StorageImage, storageRoute } = createStorageImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + lifetime: 600_000, + }) + const url = new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + , + ), + ), + ), + 'https://app.example', + ) + expect(url.pathname).toBe('/api/storage-images') + const response = await storageRoute(new Request(url)) + const location = response.headers.get('location') + expect(response.status).toBe(307) + expect(location).not.toBeNull() + if (location === null) throw new Error('Expected CDN location') + expect(new URL(location).searchParams.get('exp')).toBe( + String(Date.parse('2029-01-01T12:10:00Z')), + ) + expect(connection).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }) + + test('rejects a missing prefix policy at factory time for untyped callers', () => { + expect(() => + Reflect.apply(createStorageImages, undefined, [ + { ...baseConfiguration, allowedPathPrefixes: undefined }, + ]), + ).toThrow('images, allowedPathPrefixes or allowWorkspaceRoot: true is required') + }) + + test('exports only the named StorageImage component, not the unpublished Image alias', () => { + expect(Object.keys(createStorageImages(baseConfiguration))).toEqual(['StorageImage']) + }) + + test('shares CDN URLs throughout the default expiry bucket, then rotates at its boundary', async () => { + const { StorageImage } = createStorageImages(baseConfiguration) + const render = async (): Promise => + getFirstCandidate( + parseMarkup( + await renderAsync( + , + ), + ), + ) + const first = await render() + vi.setSystemTime('2029-01-01T12:29:59.999Z') + expect(await render()).toBe(first) + vi.setSystemTime('2029-01-01T12:30:00.000Z') + expect(await render()).not.toBe(first) + }) + + test('shares redirect targets throughout the default expiry bucket while still authorizing', async () => { + const { authorize, storageRoute, url } = getStorageRouteCandidate() + const request = new Request(url, { headers: { Authorization: 'Bearer allowed' } }) + const first = await storageRoute(request) + vi.setSystemTime('2029-01-01T12:29:59.999Z') + expect((await storageRoute(request)).headers.get('location')).toBe( + first.headers.get('location'), + ) + expect(authorize).toHaveBeenCalledTimes(2) + }) + + test.each([ + 'direct', + 'redirect', + ])('renders a receipt exactly like its string equivalent with %s delivery', async (delivery) => { + const { StorageImage: Image } = createStorageImages({ + ...baseConfiguration, + ...(delivery === 'direct' ? {} : { route: '/images', authorize: () => true }), + }) + const src = { + path: 'documents/report.pdf', + width: 400, + height: 300, + asset_id: 'private-asset-id', + md5hash: 'd41d8cd98f00b204e9800998ecf8427e', + authSecret: 'secret-from-receipt', + id: 'not-an-attribute', + } + const received = parseMarkup(await renderAsync(Image({ alt: 'Report', src, layout: 'none' }))) + const expected = parseMarkup( + await renderAsync(Image({ alt: 'Report', src: src.path, width: 400, height: 300 })), + ) + expect(received.querySelector('picture')?.isEqualNode(expected.querySelector('picture'))).toBe( + true, + ) + expect(received.querySelector('img')?.getAttribute('width')).toBe('400') + expect(received.querySelector('img')?.getAttribute('height')).toBe('300') + expect(received.documentElement.outerHTML).not.toContain('private-asset-id') + expect(received.documentElement.outerHTML).not.toContain('d41d8cd98f00b204e9800998ecf8427e') + expect(received.documentElement.outerHTML).not.toContain('secret-from-receipt') + expect(received.querySelector('img')?.id).toBe('') + }) + + test.each( + [ + null, + [], + {}, + { toString: () => 'documents/report.pdf' }, + { path: 'documents/../secret.pdf', width: 400, height: 300 }, + { path: 'private/report.pdf', width: 400, height: 300 }, + { path: 'documents/report.pdf', width: '400', height: 300 }, + { path: 'documents/report.pdf', width: 0, height: 300 }, + { path: 'documents/report.pdf', width: 400, height: 1.5 }, + { path: 'documents/report.pdf', width: 400, height: Number.POSITIVE_INFINITY }, + { path: 'documents/report.pdf', width: Number.MAX_SAFE_INTEGER + 1, height: 300 }, + ].map((src) => ({ src })), + )('rejects malformed or unauthorized receipt $src before request I/O', ({ src }) => { + const { StorageImage: Image } = createStorageImages(baseConfiguration) + expect(() => Reflect.apply(Image, undefined, [{ alt: 'Invalid', src }])).toThrow() + expect(connection).not.toHaveBeenCalled() + }) + + test('rejects invalid presentation dimensions from JavaScript callers', () => { + const { StorageImage: Image } = createStorageImages(baseConfiguration) + expect(() => + Reflect.apply(Image, undefined, [ + { + alt: 'Ambiguous', + src: { path: 'documents/report.pdf', width: 400, height: 300 }, + width: -1, + height: 300, + }, + ]), + ).toThrow() + expect(connection).not.toHaveBeenCalled() + }) + + test('snapshots receipt geometry and path before request-time mutation', async () => { + const { StorageImage: Image } = createStorageImages(baseConfiguration) + const src = { path: 'documents/report.pdf', width: 400, height: 300 } + connection.mockImplementationOnce(() => { + Object.assign(src, { path: 'private/changed.pdf', width: 0, height: 0 }) + return Promise.resolve(undefined) + }) + const document = parseMarkup( + await renderAsync(Image({ alt: 'Stable receipt', src, widths: [400] })), + ) + const url = parseSmartCdnUrl(getFirstCandidate(document), baseConfiguration) + expect(url.input).toBe('documents/report.pdf') + expect(url.urlParams).toMatchObject({ h: '300', w: '400' }) + expect(document.querySelector('img')?.getAttribute('width')).toBe('400') + }) + + test.each([ + 'string', + 'receipt', + ])('snapshots %s dimensions before reading other attributes in redirect delivery', async (kind) => { + const { StorageImage: Image, storageRoute } = createStorageImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + }) + const source = { path: 'documents/report.pdf', width: 400, height: 300 } + const props = { + alt: 'Stable redirect', + src: source.path, + width: 400, + height: 300, + widths: [400], + } + const sourceProps = kind === 'string' ? props : { alt: props.alt, src: source, widths: [400] } + Object.defineProperty(sourceProps, 'id', { + enumerable: true, + get() { + Object.assign(source, { path: 'private/changed.pdf', width: 0, height: 0 }) + props.width = 0 + props.height = 0 + return 'original-id' + }, + }) + const markup = renderToStaticMarkup(Image(sourceProps)) + const document = parseMarkup(markup) + const response = await storageRoute( + new Request(new URL(getFirstCandidate(document), 'https://app.example')), + ) + expect(response.status).toBe(307) + const location = response.headers.get('location') + if (location === null) throw new Error('Expected an authorized CDN target') + const candidate = parseSmartCdnUrl(location, baseConfiguration) + expect(candidate.input).toBe('documents/report.pdf') + expect(candidate.urlParams).toMatchObject({ h: '300', w: '400' }) + expect(document.querySelector('img')?.getAttribute('width')).toBe('400') + expect(connection).not.toHaveBeenCalled() + }) + + test('reserves native image geometry while request-time signing is suspended', async () => { + let resolveConnection: (value: undefined) => void = () => { + throw new Error('Connection was not initialized') + } + const pending = new Promise((resolve) => { + resolveConnection = resolve + }) + connection.mockImplementationOnce(() => pending) + const { StorageImage: Image } = createStorageImages(baseConfiguration) + const stream = await renderToReadableStream( +
+ Hero +

Following content

+
, + ) + const reader = stream.getReader() + const shell = new TextDecoder().decode((await reader.read()).value) + const placeholder = parseMarkup(shell).querySelector('img') + // Always resolve the request so a failed assertion cannot leak a suspended stream. + resolveConnection(undefined) + await stream.allReady + let remaining = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + remaining += new TextDecoder().decode(value) + } + const image = parseMarkup(remaining).getElementById('hero') + + expect(placeholder?.hasAttribute('id')).toBe(false) + expect(placeholder?.hasAttribute('aria-describedby')).toBe(false) + expect(placeholder?.hasAttribute('aria-labelledby')).toBe(false) + expect(image?.getAttribute('id')).toBe('hero') + expect(image?.getAttribute('aria-describedby')).toBe('hero-caption') + expect(image?.getAttribute('aria-labelledby')).toBe('hero hero-caption') + expect(placeholder?.getAttribute('width')).toBe('2400') + expect(placeholder?.getAttribute('height')).toBe('1600') + expect(placeholder?.getAttribute('class')).toBe('hero') + // Consumer selectors such as picture > img must apply before signing resolves too. + expect(placeholder?.parentElement?.tagName).toBe('PICTURE') + expect(placeholder?.getAttribute('style')).toBe( + 'display:block;max-width:960px;width:100%;height:auto;visibility:hidden', + ) + expect(placeholder?.getAttribute('aria-hidden')).toBe('true') + expect(placeholder?.hasAttribute('inert')).toBe(true) + expect(placeholder?.hasAttribute('src')).toBe(false) + expect(shell).not.toContain('cdn.example') + expect(shell).not.toContain('imageSrcSet') + expect(shell).toContain('Following content') + expect(image?.getAttribute('style')).toBe( + 'display:block;max-width:960px;width:100%;height:auto', + ) + expect(image?.getAttribute('width')).toBe('2400') + expect(image?.getAttribute('height')).toBe('1600') + expect(image?.getAttribute('src')).toContain('cdn.example') + }) + + test('keeps an explicit direct Suspense fallback as an override', async () => { + let resolveConnection: (value: undefined) => void = () => { + throw new Error('Connection was not initialized') + } + const pending = new Promise((resolve) => { + resolveConnection = resolve + }) + connection.mockImplementationOnce(() => pending) + const { StorageImage: Image } = createStorageImages(baseConfiguration) + const stream = await renderToReadableStream( +
+ Custom shellCustom preview

} + width={400} + /> +

Following content

+
, + ) + const reader = stream.getReader() + const shell = new TextDecoder().decode((await reader.read()).value) + resolveConnection(undefined) + await stream.allReady + await reader.cancel() + + expect(parseMarkup(shell).querySelector('[role="status"]')?.textContent).toBe('Custom preview') + expect(parseMarkup(shell).querySelector('img')).toBeNull() + }) + test('allows explicit widths while making sizes optional', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { StorageImage: Image } = createStorageImages(baseConfiguration) const document = parseMarkup( await renderAsync( { ) const source = document.querySelector('source') - expect(source?.hasAttribute('sizes')).toBe(false) + expect(source?.getAttribute('sizes')).toBe('auto, 100vw') expect(source?.getAttribute('srcset')).toContain('200w') expect(source?.getAttribute('srcset')).toContain('400w') expect(source?.getAttribute('srcset')).toContain('800w') }) + test.each([ + 'direct', + 'redirect', + ])('preserves native attributes and descriptions in %s delivery', async (delivery) => { + const { StorageImage: Image } = createStorageImages({ + ...baseConfiguration, + ...(delivery === 'direct' ? {} : { authorize: () => true, route: '/api/private-images' }), + }) + const document = parseMarkup( + await renderAsync( +
+ Report preview +
The annual report
+
, + ), + ) + const image = document.getElementById('report-preview') + expect(image?.getAttribute('aria-describedby')).toBe('report-caption') + expect( + document.getElementById(image?.getAttribute('aria-describedby') ?? '')?.textContent, + ).toBe('The annual report') + expect(image?.getAttribute('title')).toBe('Annual report') + expect(image?.getAttribute('role')).toBe('img') + expect(image?.getAttribute('data-document')).toBe('report') + expect(image?.getAttribute('sizes')).toBe('auto') + }) + + test.each([ + 'direct', + 'redirect', + ])('rejects non-string alt before rendering in %s delivery', (delivery) => { + const { StorageImage: Image } = createStorageImages({ + ...baseConfiguration, + ...(delivery === 'direct' ? {} : { authorize: () => true, route: '/api/private-images' }), + }) + expect(() => + Reflect.apply(Image, undefined, [ + { alt: { text: 'Report' }, height: 600, src: 'documents/report.pdf', width: 800 }, + ]), + ).toThrow('Image alt must be a string') + }) + test('rejects coercible Storage sources before signing', () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { StorageImage: Image } = createStorageImages(baseConfiguration) const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg') expect(() => @@ -117,7 +1530,7 @@ describe('createTransloaditImage', () => { }) test('request-renders direct Storage previews with bounded stable signatures', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { StorageImage: Image } = createStorageImages(baseConfiguration) const render = async (): Promise => { const markup = await renderAsync( { const firstFallback = new URL(firstDocument.querySelector('img')?.getAttribute('src') ?? '') expect(connection).toHaveBeenCalledOnce() - expect(firstSource.pathname).toContain('/builtin%2Fstorage-preview%400.0.1/') + expect(firstSource.pathname).toContain('/builtin%2Fstorage-preview%400.0.2/') expect(firstSource.searchParams.get('f')).toBe('webp') expect(firstSource.searchParams.get('h')).toBe('150') expect(firstSource.searchParams.get('q')).toBe('61') - expect(firstFallback.searchParams.get('f')).toBe('jpg') + expect(firstFallback.searchParams.has('f')).toBe(false) expect(firstDocument.querySelector('img')?.getAttribute('loading')).toBe('lazy') - expect(firstSource.searchParams.get('exp')).toBe(String(Date.parse('2029-01-01T13:05:00Z'))) + expect(firstSource.searchParams.get('exp')).toBe(String(Date.parse('2029-01-01T13:00:00Z'))) - vi.setSystemTime('2029-01-01T12:04:59.999Z') + vi.setSystemTime('2029-01-01T12:29:59.999Z') const sameWindow = await render() expect(sameWindow.querySelector('source')?.getAttribute('srcset')).toBe( firstDocument.querySelector('source')?.getAttribute('srcset'), ) - vi.setSystemTime('2029-01-01T12:05:00.000Z') + vi.setSystemTime('2029-01-01T12:30:00.000Z') const nextWindow = await render() expect(nextWindow.querySelector('source')?.getAttribute('srcset')).not.toBe( firstDocument.querySelector('source')?.getAttribute('srcset'), @@ -161,11 +1574,11 @@ describe('createTransloaditImage', () => { }) test('denies private paths by default and matches explicit directory boundaries', () => { - const { Image: denyAllImage } = createTransloaditImage({ + const { StorageImage: denyAllImage } = createStorageImages({ ...baseConfiguration, - storage: {}, + allowedPathPrefixes: [], }) - const { Image } = createTransloaditImage(baseConfiguration) + const { StorageImage: Image } = createStorageImages(baseConfiguration) expect(() => denyAllImage({ @@ -187,12 +1600,14 @@ describe('createTransloaditImage', () => { }) test('snapshots direct Storage props before crossing the request boundary', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { StorageImage: Image } = createStorageImages(baseConfiguration) let height = 300 + let id = 'original-id' let path = 'documents/report.pdf' let width = 400 connection.mockImplementationOnce(() => { height = 0 + id = 'mutated-id' path = 'private/secret.pdf' width = 0 return Promise.resolve(undefined) @@ -202,6 +1617,9 @@ describe('createTransloaditImage', () => { get height() { return height }, + get id() { + return id + }, get src() { return path }, @@ -219,6 +1637,7 @@ describe('createTransloaditImage', () => { expect(candidate.input).toBe('documents/report.pdf') expect(candidate.urlParams.h).toBe('300') expect(candidate.urlParams.w).toBe('400') + expect(document.querySelector('img')?.id).toBe('original-id') }) test('renders opaque authorized-route capabilities without request I/O or credentials', () => { @@ -236,16 +1655,12 @@ describe('createTransloaditImage', () => { }) test('prepends basePath while accepting Next.js stripped handler paths', async () => { - const { Image, storageRoute } = createTransloaditImage({ + const { StorageImage: Image, storageRoute } = createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { - authorize: () => true, - basePath: '/app', - route: '/api/private-images', - }, - }, + allowedPathPrefixes: ['documents/'], + authorize: () => true, + basePath: '/app', + route: '/api/private-images', }) const markup = renderToStaticMarkup( Base path, @@ -272,12 +1687,11 @@ describe('createTransloaditImage', () => { return 'false' }, }) - const { Image, storageRoute } = createTransloaditImage({ + const { StorageImage: Image, storageRoute } = createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: malformedAuthorize, route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: malformedAuthorize, + route: '/api/private-images', }) const markup = renderToStaticMarkup( Strict ACL, @@ -304,19 +1718,18 @@ describe('createTransloaditImage', () => { expect(response.headers.get('referrer-policy')).toBe('no-referrer') expect(authorize).toHaveBeenCalledOnce() expect(authorize).toHaveBeenCalledWith({ path: 'documents/report.pdf', request }) - expect(target.template).toBe('builtin/storage-preview@0.0.1') + expect(target.template).toBe('builtin/storage-preview@0.0.2') expect(target.input).toBe('documents/report.pdf') - expect(target.urlParams).toMatchObject({ f: 'avif', h: '240', q: '45', r: 'pad', w: '320' }) - expect(target.auth?.expiresAt).toBe(Date.parse('2029-01-01T13:05:00Z')) + expect(target.urlParams).toEqual({ bg: '#00000000', f: 'avif', h: '240', q: '45', w: '320' }) + expect(target.auth?.expiresAt).toBe(Date.parse('2029-01-01T13:00:00Z')) }) test('keeps cached capabilities valid while rotating only their redirect targets', async () => { - const { Image, storageRoute } = createTransloaditImage({ + const { StorageImage: Image, storageRoute } = createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: () => true, route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: () => true, + route: '/api/private-images', }) const render = (): URL => { const markup = renderToStaticMarkup( @@ -327,7 +1740,7 @@ describe('createTransloaditImage', () => { const first = render() const firstRedirect = await storageRoute(new Request(first)) - vi.setSystemTime('2029-01-01T12:05:00Z') + vi.setSystemTime('2029-01-01T13:00:00Z') const second = render() const secondRedirect = await storageRoute(new Request(second)) const cachedRedirect = await storageRoute(new Request(first)) @@ -339,6 +1752,49 @@ describe('createTransloaditImage', () => { expect(connection).not.toHaveBeenCalled() }) + test('refreshes an expired target from cached markup, then denies new grants after revocation', async () => { + const authorize = vi.fn(() => true) + const { StorageImage: Image, storageRoute } = createStorageImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + authorize, + route: '/api/private-images', + lifetime: 5 * 60 * 1000, + rotationIntervalMs: 30 * 1000, + }) + const document = parseMarkup( + renderToStaticMarkup( + Long-lived preview, + ), + ) + const originalCapability = new URL(getFirstCandidate(document), 'https://app.example') + const firstResponse = await storageRoute(new Request(originalCapability)) + const firstLocation = firstResponse.headers.get('location') + if (firstLocation === null) throw new Error('Expected the first authorized target') + const originalExpiry = Number(new URL(firstLocation).searchParams.get('exp')) + expect(originalExpiry).toBe(Date.parse('2029-01-01T12:07:00Z')) + + vi.setSystemTime(originalExpiry + 1) + const renewed = await storageRoute(new Request(originalCapability)) + const renewedLocation = renewed.headers.get('location') + if (renewedLocation === null) throw new Error('Expected a renewed authorized target') + expect(renewed.status).toBe(307) + expect(renewedLocation).not.toBe(firstLocation) + expect(Number(new URL(renewedLocation).searchParams.get('exp'))).toBe( + Date.parse('2029-01-01T12:12:00Z'), + ) + expect(renewed.headers.get('cache-control')).toBe('private, no-store') + expect(await renewed.text()).toBe('') + + authorize.mockReturnValue(false) + const denied = await storageRoute(new Request(originalCapability)) + expect(denied.status).toBe(404) + expect(denied.headers.get('location')).toBeNull() + expect(denied.headers.get('cache-control')).toBe('private, no-store') + expect(await denied.text()).toBe('') + expect(authorize).toHaveBeenCalledTimes(3) + }) + test('binds capabilities to the secret, workspace, Template, route, and basePath', async () => { const { url } = getStorageRouteCandidate() const authorize = vi.fn(() => true) @@ -355,13 +1811,13 @@ describe('createTransloaditImage', () => { storageTemplate?: string workspace?: string } = {}) => - createTransloaditImage({ + createStorageImages({ ...baseConfiguration, authSecret: candidateSecret, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize, basePath, route }, - }, + allowedPathPrefixes: ['documents/'], + authorize, + basePath, + route, template: storageTemplate, workspace, }).storageRoute @@ -492,22 +1948,23 @@ describe('createTransloaditImage', () => { }) test('rejects direct-only suspense props in static redirect mode', () => { - const { Image } = createTransloaditImage({ + const { StorageImage: Image } = createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: () => true, route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: () => true, + route: '/api/private-images', }) expect(() => - Image({ - alt: 'No suspension', - height: 300, - src: 'documents/report.pdf', - suspenseFallback: 'Loading', - width: 400, - }), + Reflect.apply(Image, undefined, [ + { + alt: 'No suspension', + height: 300, + src: 'documents/report.pdf', + suspenseFallback: 'Loading', + width: 400, + }, + ]), ).toThrow('suspenseFallback is only used by direct Storage delivery') }) @@ -522,7 +1979,7 @@ describe('createTransloaditImage', () => { 'w', ])('reserves image-policy parameter %s from global URL parameters', (parameter) => { expect(() => - createTransloaditImage({ + createStorageImages({ ...baseConfiguration, urlParams: { [parameter]: 'caller-controlled' }, }), @@ -530,59 +1987,51 @@ describe('createTransloaditImage', () => { }) test('validates credentials, route configuration, and bounded expiry', () => { - expect(() => createTransloaditImage({ ...baseConfiguration, authKey: '' })).toThrow( + expect(() => createStorageImages({ ...baseConfiguration, authKey: '' })).toThrow( 'authKey must be a non-empty string', ) expect(() => - createTransloaditImage({ ...baseConfiguration, baseUrl: 'ftp://cdn.example/file' }), + createStorageImages({ ...baseConfiguration, baseUrl: 'ftp://cdn.example/file' }), ).toThrow('baseUrl must be an absolute HTTP(S) URL') expect(() => - createTransloaditImage({ + createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - expiresInMs: 48 * 60 * 60 * 1000, - rotationIntervalMs: 5 * 60 * 1000, - }, + allowedPathPrefixes: ['documents/'], + lifetime: 48 * 60 * 60 * 1000 + 1, + rotationIntervalMs: 5 * 60 * 1000, }), ).toThrow('must not exceed 48 hours') expect(() => - createTransloaditImage({ + createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: () => true, route: 'api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: () => true, + route: 'api/private-images', }), - ).toThrow('storage.delivery.route must be one absolute application path') + ).toThrow('route must be one absolute application path') expect(() => - createTransloaditImage({ + createStorageImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { - authorize: () => true, - basePath: '/app/', - route: '/api/private-images', - }, - }, + allowedPathPrefixes: ['documents/'], + authorize: () => true, + basePath: '/app/', + route: '/api/private-images', }), - ).toThrow('storage.delivery.basePath must be one absolute path without a trailing slash') + ).toThrow('basePath must be one absolute path without a trailing slash') expect(() => - Reflect.apply(createTransloaditImage, undefined, [ + Reflect.apply(createStorageImages, undefined, [ { ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: 'yes', route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: 'yes', + route: '/api/private-images', }, ]), - ).toThrow('storage.delivery.authorize must be a function') + ).toThrow('authorize must be a function') }) test('keeps template selection in trusted factory configuration', async () => { - const { Image } = createTransloaditImage({ + const { StorageImage: Image } = createStorageImages({ ...baseConfiguration, template: 'my-storage-preview', }) diff --git a/packages/img/test/next.test.tsx b/packages/img/test/next.test.tsx index 9a3e2c1d..cc638a62 100644 --- a/packages/img/test/next.test.tsx +++ b/packages/img/test/next.test.tsx @@ -5,12 +5,13 @@ import type { Root } from 'react-dom/client' import type { TransloaditImageModel } from '../src/index.ts' -import { act } from 'react' -import { hydrateRoot } from 'react-dom/client' +import { act, Children, createElement, isValidElement } from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' import { renderToStaticMarkup, renderToString } from 'react-dom/server' import { afterEach, describe, expect, test, vi } from 'vitest' import { TransloaditPicture } from '../src/next/index.tsx' +import { StorageImageErrorBoundary } from '../src/next/StorageImageErrorBoundary.tsx' Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, @@ -40,25 +41,30 @@ const model: TransloaditImageModel = { function renderPicture( overrides: Partial<{ - deferUntilHydrated: boolean + alt: unknown loading: 'eager' | 'lazy' - media: string - mediaPlaceholderSrc: string + priority: boolean preload: boolean + sizes: string + style: unknown }> = {}, ): Document { const markup = renderToStaticMarkup( - , + // Intentionally allow invalid JS prop combinations to exercise runtime guards too. + Reflect.apply(createElement, undefined, [ + TransloaditPicture, + { + alt: 'A canal house', + className: 'photo', + fetchPriority: 'high', + height: 300, + loading: 'lazy', + model, + sizes: '(min-width: 800px) 640px, 100vw', + width: 400, + ...overrides, + }, + ]), ) return new DOMParser().parseFromString(markup, 'text/html') } @@ -68,6 +74,306 @@ afterEach(() => { }) describe('TransloaditPicture', () => { + test('the public renderer rejects non-PNG and CSS-bearing blur input', () => { + const props = { model, alt: 'Hero', width: 400, height: 300 } + expect(() => + renderToStaticMarkup( + , + ), + ).toThrow(/blurDataURL.*PNG/) + expect(() => + renderToStaticMarkup( + , + ), + ).toThrow(/blurDataURL.*PNG/) + }) + + test('preload names the eager responsive preload macro without emitting a native preload attribute', () => { + const doc = renderPicture({ loading: undefined, preload: true }) + expect(doc.querySelector('img')?.getAttribute('loading')).toBe('eager') + expect(doc.querySelector('link[rel="preload"]')?.getAttribute('imagesrcset')).toContain('640w') + expect(doc.querySelector('img')?.hasAttribute('preload')).toBe(false) + expect(() => renderPicture({ loading: 'lazy', preload: true })).toThrow( + 'cannot use lazy loading', + ) + }) + + test('eager auto sizing drops auto and the priority alias warns only in development', () => { + vi.stubEnv('NODE_ENV', 'development') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const doc = renderPicture({ loading: undefined, priority: true, sizes: 'auto, 80vw' }) + expect(doc.querySelector('source')?.getAttribute('sizes')).toBe('80vw') + expect(doc.querySelector('img')?.hasAttribute('sizes')).toBe(false) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('priority is deprecated; use preload'), + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('auto sizes require lazy loading')) + warn.mockClear() + vi.stubEnv('NODE_ENV', 'production') + renderPicture({ loading: undefined, priority: true }) + expect(warn).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }) + test.each([ + ['/images/my photo.jpg', '/images/my%20photo.jpg'], + ['/images/photo,,', '/images/photo%2C%2C'], + [',,/images/photo.jpg', '%2C%2C/images/photo.jpg'], + ['data:image/gif;base64,AAAA', 'data:image/gif;base64,AAAA'], + ])('escapes an art-direction JPEG fallback %s without changing its URL semantics', (fallbackUrl, expected) => { + const markup = renderToStaticMarkup( + , + ) + const parsed = new DOMParser().parseFromString(markup, 'text/html') + expect(parsed.querySelector('source[type="image/jpeg"]')?.getAttribute('srcset')).toBe(expected) + }) + test('a session-dependent retry key recovers the same failed URL without an automatic retry loop', async () => { + vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(false) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + const props = { + alt: 'Private photo', + height: 300, + width: 400, + model, + errorFallback:

Sign in

, + } + try { + await act(() => root.render()) + await act(() => container.querySelector('img')?.dispatchEvent(new Event('error'))) + expect(container.querySelector('[role="status"]')?.textContent).toBe('Sign in') + await act(() => root.render()) + expect(container.querySelector('[role="status"]')?.textContent).toBe('Sign in') + await act(() => root.render()) + expect(container.querySelector('img')?.getAttribute('src')).toBe(model.fallbackUrl) + expect(container.querySelector('[role="status"]')).toBeNull() + } finally { + await act(() => root.unmount()) + container.remove() + } + }) + + test('keeps the Flight error-boundary key compact instead of repeating every signed candidate', () => { + const picture = TransloaditPicture({ + alt: 'Photo', + height: 300, + width: 400, + model: { ...model, artDirection: [{ media: '(max-width: 639px)', model }] }, + errorFallback:

Image unavailable

, + }) + if (!isValidElement<{ children: ReactNode }>(picture)) + throw new Error('Expected a picture fragment') + const boundary = Children.toArray(picture.props.children).find( + (child) => isValidElement(child) && child.type === StorageImageErrorBoundary, + ) + if (!isValidElement(boundary)) throw new Error('Expected the image-load boundary') + expect(boundary.key).toBeTypeOf('string') + expect(boundary.key?.length).toBeLessThanOrEqual(66) + expect(boundary.key).not.toContain('https://') + }) + + test.each([ + { timing: 'after hydration', artDirection: false, changeCandidates: false }, + { timing: 'before hydration', artDirection: false, changeCandidates: false }, + { timing: 'after hydration', artDirection: true, changeCandidates: false }, + { timing: 'before hydration', artDirection: true, changeCandidates: false }, + { timing: 'after hydration', artDirection: false, changeCandidates: true }, + { timing: 'after hydration', artDirection: true, changeCandidates: true }, + ])('optional fallback handles a failed image $timing (art direction: $artDirection) without replacing SSR markup', async ({ + timing, + artDirection, + changeCandidates, + }) => { + const container = document.createElement('div') + document.body.append(container) + const props = { + alt: 'Photo', + height: 300, + width: 400, + priority: artDirection, + model: artDirection + ? { ...model, artDirection: [{ media: '(max-width: 639px)', model }] } + : model, + } + const picture = ( + Image unavailable

} /> + ) + const original = renderToString() + const markup = renderToString(picture) + expect(markup).toBe(original) + container.innerHTML = markup + const complete = vi + .spyOn(HTMLImageElement.prototype, 'complete', 'get') + .mockReturnValue(timing === 'before hydration') + vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(0) + vi.spyOn(HTMLImageElement.prototype, 'currentSrc', 'get').mockReturnValue(model.fallbackUrl) + let root: Root | undefined + const recoverableErrors: unknown[] = [] + await act(() => { + root = hydrateRoot(container, picture, { + onRecoverableError: (error) => recoverableErrors.push(error), + }) + }) + if (timing === 'after hydration') { + await act(() => { + container.querySelector('img')?.dispatchEvent(new Event('error')) + }) + } + expect(container.querySelector('[role="status"]')?.textContent).toBe('Image unavailable') + expect(container.querySelector('picture')).toBeNull() + expect(recoverableErrors).toEqual([]) + complete.mockReturnValue(false) + const changedModel = { ...model, sources: model.sources.slice(1) } + const replacement = changeCandidates + ? artDirection + ? { ...model, artDirection: [{ media: '(max-width: 639px)', model: changedModel }] } + : changedModel + : { ...model, fallbackUrl: 'https://assets.example/replacement.jpg' } + await act(() => { + root?.render( + Image unavailable

} + model={replacement} + />, + ) + }) + expect(container.querySelector('img')?.src).toBe(replacement.fallbackUrl) + expect(container.querySelector('[role="status"]')).toBeNull() + act(() => root?.unmount()) + container.remove() + }) + + test.each([ + { description: 'A canal house' }, + undefined, + 123, + ])('rejects a non-string alt from JavaScript: %j', (alt) => { + expect(() => renderPicture({ alt })).toThrow('Image alt must be a string') + }) + + test.each(['A canal house', ''])('preserves the supplied alt text: %j', (alt) => { + expect(renderPicture({ alt }).querySelector('img')?.getAttribute('alt')).toBe(alt) + }) + + test('retains asynchronous decoding when a wrapper forwards undefined', () => { + const markup = renderToStaticMarkup( + , + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + expect(document.querySelector('img')?.getAttribute('decoding')).toBe('async') + }) + + test.each([ + 'color:red', + ['color:red'], + 123, + ])('rejects a non-object style from JavaScript: %j', (style) => { + expect(() => renderPicture({ style })).toThrow('Image style must be an object') + }) + + test('preserves serializable image attributes without exposing renderer or signing inputs', () => { + const markup = renderToStaticMarkup( + Reflect.apply(TransloaditPicture, undefined, [ + { + alt: 'A canal house', + 'aria-describedby': 'photo-caption', + authSecret: 'must-stay-private', + crossOrigin: 'anonymous', + 'data-photo': 'canal', + 'data-nonserializable': { privateValue: 'must-stay-private' }, + decoding: 'sync', + height: 300, + id: 'canal-photo', + model, + onLoad: () => undefined, + referrerPolicy: 'no-referrer', + role: 'img', + src: '/untrusted-original.jpg', + srcSet: '/untrusted-candidate.jpg 320w', + title: 'Amsterdam', + urlParams: { sig: 'must-stay-private' }, + width: 400, + }, + ]), + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + const image = document.getElementById('canal-photo') + + expect(image?.getAttribute('aria-describedby')).toBe('photo-caption') + expect(image?.getAttribute('title')).toBe('Amsterdam') + expect(image?.getAttribute('role')).toBe('img') + expect(image?.getAttribute('data-photo')).toBe('canal') + expect(image?.getAttribute('decoding')).toBe('sync') + expect(image?.getAttribute('crossorigin')).toBe('anonymous') + expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(image?.getAttribute('src')).toBe(model.fallbackUrl) + expect(image?.hasAttribute('srcset')).toBe(false) + expect(markup).not.toContain('must-stay-private') + expect(markup).not.toContain('untrusted') + expect(image?.hasAttribute('model')).toBe(false) + expect(image?.hasAttribute('onload')).toBe(false) + expect(image?.hasAttribute('data-nonserializable')).toBe(false) + }) + + test('defaults lazy sizing to the CSS box with a viewport fallback', () => { + const document = renderPicture({ sizes: undefined }) + expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([ + 'auto, 100vw', + 'auto, 100vw', + ]) + expect(document.querySelector('img')?.getAttribute('sizes')).toBe('auto') + }) + + test.each([ + 'auto', + 'auto, 100vw', + 'AUTO, 400px', + ])('activates lazy automatic sizing for %s', (sizes) => { + const document = renderPicture({ sizes }) + expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([ + sizes, + sizes, + ]) + expect(document.querySelector('img')?.getAttribute('sizes')).toBe('auto') + expect(document.querySelector('img')?.getAttribute('loading')).toBe('lazy') + }) + + test.each([ + ['auto', '100vw'], + ['auto, 100vw', '100vw'], + ['AUTO, 400px', '400px'], + ])('uses the eager automatic sizing fallback for %s', (sizes, fallback) => { + expect( + renderPicture({ loading: 'eager', sizes }).querySelector('source')?.getAttribute('sizes'), + ).toBe(fallback) + expect( + renderPicture({ loading: undefined, priority: true, sizes }) + .querySelector('source') + ?.getAttribute('sizes'), + ).toBe(fallback) + }) + test('renders native picture sources and the supplied fallback', () => { const document = renderPicture() const sources = [...document.querySelectorAll('source')] @@ -118,7 +424,7 @@ describe('TransloaditPicture', () => { test('preloads only the preferred source', () => { const document = renderPicture({ loading: 'eager', - preload: true, + priority: true, }) const preload = document.querySelector('link[rel="preload"]') const sources = [...document.querySelectorAll('picture source')] @@ -132,122 +438,40 @@ describe('TransloaditPicture', () => { expect(image?.getAttribute('src')).toBe(model.fallbackUrl) }) - test('rejects a media-gated preload instead of letting React deduplicate it incorrectly', () => { - expect(() => - renderPicture({ loading: 'eager', media: '(min-width: 768px)', preload: true }), - ).toThrow('A media-gated Transloadit image cannot be preloaded') - }) - - test('escapes whitespace in a media-gated fallback srcset URL', () => { + test('uses the same request policy on the preload and the image', () => { const document = new DOMParser().parseFromString( renderToStaticMarkup( , - ), - 'text/html', - ) - - expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( - '/images/my%20photo.jpg', - ) - }) - - test('encodes trailing commas in a media-gated fallback srcset URL', () => { - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - , - ), - 'text/html', - ) - - expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( - '/images/photo%2C%2C', - ) - }) - - test('encodes leading commas in a media-gated fallback srcset URL', () => { - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - , - ), - 'text/html', - ) - - expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( - '%2C%2C/images/photo.jpg', - ) - }) - - test('preserves the payload delimiter in a media-gated data URL fallback', () => { - const fallbackUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' - const document = new DOMParser().parseFromString( - renderToStaticMarkup( - , ), 'text/html', ) + const preload = document.querySelector('link[rel="preload"]') + const image = document.querySelector('img') - expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe(fallbackUrl) - }) - - test('uses a neutral inline fallback while a media condition is unmatched', () => { - const document = renderPicture({ media: '(min-width: 768px)' }) - - expect(document.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/gif;base64,/) - }) - - test('accepts a CSP-compatible media placeholder', () => { - const document = renderPicture({ - media: '(min-width: 768px)', - mediaPlaceholderSrc: '/images/transparent.gif', - }) - - expect(document.querySelector('img')?.getAttribute('src')).toBe('/images/transparent.gif') + expect(preload?.getAttribute('crossorigin')).toBe('use-credentials') + expect(preload?.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(preload?.getAttribute('imagesizes')).toBe('100vw') + expect(image?.getAttribute('crossorigin')).toBe('use-credentials') + expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer') }) test('makes preload eager by default and rejects an explicitly lazy preload', () => { - const preloaded = renderPicture({ loading: undefined, preload: true }) + const preloaded = renderPicture({ loading: undefined, priority: true }) expect(preloaded.querySelector('img')?.getAttribute('loading')).toBe('eager') - expect(() => renderPicture({ loading: 'lazy', preload: true })).toThrow( + expect(() => renderPicture({ loading: 'lazy', priority: true })).toThrow( 'A preloaded Transloadit image cannot use lazy loading', ) }) - test('keeps deferred candidate elements out of server markup', () => { - const document = renderPicture({ deferUntilHydrated: true }) - - expect(document.querySelector('noscript img')?.getAttribute('src')).toBe(model.fallbackUrl) - expect(document.querySelectorAll('source')).toHaveLength(0) - }) - test('rejects a renderer model with an empty candidate set', () => { expect(() => renderToStaticMarkup( @@ -261,58 +485,4 @@ describe('TransloaditPicture', () => { ), ).toThrow('Cannot render an empty Transloadit image source') }) - - const deferredLoadingCases: Array<{ loading: 'eager' | 'lazy'; preload: boolean }> = [ - { loading: 'eager', preload: false }, - { loading: 'lazy', preload: true }, - ] - - test.each(deferredLoadingCases)('rejects deferring an $loading image with preload=$preload', ({ - loading, - preload, - }) => { - expect(() => renderPicture({ deferUntilHydrated: true, loading, preload })).toThrow( - 'An eager or preloaded Transloadit image cannot be deferred until hydration', - ) - }) - - test('hydrates one deferred picture without a recoverable error', async () => { - function DeferredPicture(): ReactNode { - return ( - - ) - } - - const container = document.createElement('div') - const recoverableErrors: unknown[] = [] - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - container.innerHTML = renderToString() - document.body.append(container) - let root: Root | undefined - - expect(container.querySelector('noscript')).not.toBeNull() - expect(container.querySelector('picture')).toBeNull() - - await act(async () => { - root = hydrateRoot(container, , { - onRecoverableError: (error) => recoverableErrors.push(error), - }) - await Promise.resolve() - }) - - expect(container.querySelector('noscript')).toBeNull() - expect(container.querySelector('picture')).not.toBeNull() - expect(recoverableErrors).toEqual([]) - expect(consoleError).not.toHaveBeenCalled() - - act(() => root?.unmount()) - container.remove() - }) }) diff --git a/packages/img/test/public-storage.test.tsx b/packages/img/test/public-storage.test.tsx new file mode 100644 index 00000000..c18fe04d --- /dev/null +++ b/packages/img/test/public-storage.test.tsx @@ -0,0 +1,501 @@ +// @vitest-environment happy-dom +import { getSignedSmartCdnUrl, parseSmartCdnUrl } from '@transloadit/utils/node' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefined) })) +vi.mock('next/server.js', () => ({ connection })) +vi.mock('server-only', () => ({})) + +import { createStorageImages } from '../src/next/server.tsx' + +const hash = 'd41d8cd98f00b204e9800998ecf8427e' +const images = { + 'website/hero.jpg': { path: 'website/hero.jpg', width: 2400, height: 1600, md5hash: hash }, + 'private/avatar.png': { path: 'private/avatar.png', width: 400, height: 300, md5hash: hash }, +} + +function imageUrl(markup: string): string { + const src = new DOMParser() + .parseFromString(markup, 'text/html') + .querySelector('img') + ?.getAttribute('src') + if (src === undefined || src === null) throw new Error('Expected an image source') + return src +} + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'my-app') + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_SMART_CDN_KEY', + 'TRANSLOADIT_SMART_CDN_SECRET', + ]) + vi.stubEnv(name, undefined) + connection.mockClear() +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +test.each([ + { width: 1000, height: 6000 }, + { width: 6000, height: 1000 }, +])('all public candidates and fallbacks respect both dimension limits (%j)', (dimensions) => { + const { StorageImage } = createStorageImages({ + images: { 'website/large.jpg': { path: 'website/large.jpg', ...dimensions } }, + public: ['website/'], + }) + const markup = renderToStaticMarkup( + , + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + const candidates = [...document.querySelectorAll('source')].flatMap( + (source) => + source + .getAttribute('srcset') + ?.split(', ') + .map((candidate) => candidate.split(' ')[0]) ?? [], + ) + for (const url of [...candidates, imageUrl(markup)]) { + if (url === undefined) throw new Error('Expected a URL') + const { urlParams } = parseSmartCdnUrl(url) + expect(Number(urlParams?.w)).toBeLessThanOrEqual(4096) + expect(Number(urlParams?.h)).toBeLessThanOrEqual(4096) + } +}) + +test('public art-direction crops and fallback obey the height cap', () => { + const { StorageImage } = createStorageImages({ + images: { 'website/large.jpg': { path: 'website/large.jpg', width: 6000, height: 6000 } }, + public: ['website/'], + }) + const markup = renderToStaticMarkup( + , + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + for (const source of document.querySelectorAll('source')) { + for (const candidate of source.getAttribute('srcset')?.split(', ') ?? []) { + const url = candidate.split(' ')[0] + if (url === undefined) throw new Error('Expected a candidate URL') + const { urlParams } = parseSmartCdnUrl(url) + expect(Number(urlParams?.w)).toBeLessThanOrEqual(4096) + expect(Number(urlParams?.h)).toBeLessThanOrEqual(4096) + } + } + expect(Number(parseSmartCdnUrl(imageUrl(markup)).urlParams?.h)).toBeLessThanOrEqual(4096) +}) + +test('public encoding quality rejects unsupported values before emitting unusable URLs', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + expect(() => + renderToStaticMarkup(), + ).toThrow(/quality.*85/i) + expect(() => + renderToStaticMarkup(), + ).toThrow(/quality.*85/i) +}) + +test('Built-in URLs omit defaults but retain transparent format parameters and explicit dimensions', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + const fallback = parseSmartCdnUrl(imageUrl(markup)) + expect(fallback.urlParams).toEqual({ w: '2400', h: '1600', v: hash.slice(0, 16) }) + const doc = new DOMParser().parseFromString(markup, 'text/html') + const webp = doc.querySelector('source[type="image/webp"]')?.getAttribute('srcset')?.split(' ')[0] + if (webp === undefined) throw new Error('Missing WebP candidate') + expect(parseSmartCdnUrl(webp).urlParams).toEqual({ + w: '320', + h: '213', + f: 'webp', + bg: '#00000000', + v: hash.slice(0, 16), + }) +}) + +test('custom Templates keep every transform field because their defaults are not known', () => { + const { StorageImage } = createStorageImages({ + images, + public: ['website/'], + publicTemplate: 'customer-preview-template', + delivery: { urlParams: { cdn: 'required' } }, + }) + const url = imageUrl(renderToStaticMarkup()) + expect(parseSmartCdnUrl(url).urlParams).toEqual({ + w: '2400', + h: '1600', + f: 'jpg', + bg: '#ffffff', + r: 'pad', + q: '75', + cdn: 'required', + v: hash.slice(0, 16), + }) +}) + +test('nondefault crop, background and quality remain explicit on compact Built-in URLs', () => { + const { StorageImage } = createStorageImages({ + images, + public: ['website/'], + delivery: { urlParams: { cdn: 'required' } }, + }) + const url = imageUrl( + renderToStaticMarkup( + , + ), + ) + expect(parseSmartCdnUrl(url).urlParams).toEqual({ + w: '200', + h: '200', + bg: '#224466', + r: 'fillcrop', + q: '80', + cdn: 'required', + v: hash.slice(0, 16), + }) +}) + +test('a private redirect signs the compact parameters and retains authentication and expiry', async () => { + const authorize = vi.fn(() => true) + const { StorageImage, storageRoute } = createStorageImages({ + images, + authorize, + authKey: 'signing-key', + authSecret: 'signing-secret', + }) + const path = imageUrl( + renderToStaticMarkup(), + ) + const response = await storageRoute(new Request(new URL(path, 'https://app.example'))) + expect(response.status).toBe(307) + const target = response.headers.get('location') + if (target === null) throw new Error('Missing signed redirect') + const parsed = parseSmartCdnUrl(target) + expect(parsed.urlParams).toEqual({ w: '400', h: '300' }) + expect(parsed.auth?.expiresAt).toBeGreaterThan(Date.now()) + if (parsed.auth === undefined) throw new Error('Missing signature') + expect(target).toBe( + getSignedSmartCdnUrl({ + workspace: 'my-app', + authKey: 'signing-key', + authSecret: 'signing-secret', + expiresAt: parsed.auth.expiresAt, + input: 'private/avatar.png', + template: 'builtin/storage-preview@0.0.2', + urlParams: { w: 400, h: 300 }, + }), + ) + expect(authorize).toHaveBeenCalledOnce() + expect(await response.text()).toBe('') +}) + +test('public receipt images render permanent versioned URLs without any signing credentials', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + const parsed = parseSmartCdnUrl(imageUrl(markup)) + expect(parsed.template).toBe('builtin/public-preview@0.0.1') + expect(parsed.auth).toBeUndefined() + expect(parsed.urlParams.v).toBe(hash.slice(0, 16)) + expect(markup).not.toMatch(/auth_key|sig=|exp=|\/api\/storage-images|visibility:hidden/) + expect(connection).not.toHaveBeenCalled() +}) + +test('an empty catalog can declare public directories before its first upload', () => { + const { StorageImage } = createStorageImages({ images: {}, public: ['website/'] }) + const markup = renderToStaticMarkup() + expect(parseSmartCdnUrl(imageUrl(markup)).auth).toBeUndefined() + expect(() => StorageImage({ src: images['private/avatar.png'], alt: 'Private' })).toThrow( + /outside the configured allowed prefixes/, + ) +}) + +test.each([ + { allowedPathPrefixes: [] }, + { allowedPathPrefixes: ['private/'] }, +])('public declarations cannot widen an explicit allowed policy $allowedPathPrefixes', ({ + allowedPathPrefixes, +}) => { + expect(() => + createStorageImages({ images: {}, allowedPathPrefixes, public: ['website/'] }), + ).toThrow(/public prefixes must be within allowedPathPrefixes/) +}) + +test('public rendering never validates unused secret env and is independent of clock and private lifetime', () => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', ' invalid ') + vi.stubEnv('TRANSLOADIT_SECRET', ' invalid ') + const now = vi.spyOn(Date, 'now').mockReturnValue(1_900_000_000_000) + const render = (): string => { + const { StorageImage } = createStorageImages({ images, public: ['website/'], lifetime: '2h' }) + return renderToStaticMarkup() + } + const first = render() + now.mockReturnValue(2_900_000_000_000) + expect(render()).toBe(first) +}) + +test('changed bytes get a new public cache key; receipt hashes are snapshotted', () => { + const catalog = structuredClone(images) + const { StorageImage } = createStorageImages({ images: catalog, public: ['website/'] }) + catalog['website/hero.jpg'].md5hash = 'a'.repeat(32) + const old = imageUrl(renderToStaticMarkup()) + const current = createStorageImages({ images: catalog, public: ['website/'] }) + const next = imageUrl( + renderToStaticMarkup(), + ) + expect(new URL(old).searchParams.get('v')).toBe(hash.slice(0, 16)) + expect(new URL(next).searchParams.get('v')).toBe('a'.repeat(16)) + expect(next).not.toBe(old) +}) + +test('a geometry-only receipt uses the ordinary public cache policy without inventing a version', () => { + const { StorageImage } = createStorageImages({ + images: { 'website/legacy.jpg': { path: 'website/legacy.jpg', width: 800, height: 600 } }, + public: ['website/'], + }) + const url = new URL( + imageUrl(renderToStaticMarkup()), + ) + expect(url.searchParams.has('v')).toBe(false) + expect(url.searchParams.has('sig')).toBe(false) +}) + +test.each([ + `${'a'.repeat(512)}/`, + `${'é'.repeat(256)}/`, +])('rejects an oversized public directory %s', (prefix) => { + expect(() => createStorageImages({ allowedPathPrefixes: [prefix], public: [prefix] })).toThrow( + /512/, + ) +}) + +test('public directories can contain exactly 512 UTF-8 bytes', () => { + const prefix = `${'é'.repeat(255)}a/` + expect(() => + createStorageImages({ allowedPathPrefixes: [prefix], public: [prefix] }), + ).not.toThrow() +}) + +test('an explicit key cannot silently borrow an environment secret from another credential', () => { + vi.stubEnv('TRANSLOADIT_KEY', 'combined-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'combined-secret') + const { StorageImage } = createStorageImages({ + images, + authKey: 'different-key', + authorize: () => true, + }) + expect(() => StorageImage({ src: 'private/avatar.png', alt: 'Private' })).toThrow(/authSecret/) +}) + +test('a partial Smart CDN override cannot borrow the combined key secret', () => { + vi.stubEnv('TRANSLOADIT_KEY', 'combined-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'combined-secret') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'different-key') + const { StorageImage } = createStorageImages({ images, authorize: () => true }) + expect(() => StorageImage({ src: 'private/avatar.png', alt: 'Private' })).toThrow( + /TRANSLOADIT_SMART_CDN_SECRET/, + ) +}) + +test('a public-only selection cannot silently sign a private catalog member', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + expect(() => StorageImage({ src: 'private/avatar.png', alt: 'Private' })).toThrow( + /authorize.*delivery/, + ) + expect(connection).not.toHaveBeenCalled() +}) + +test('mixed public/private uses unsigned public delivery and a signed authorized private target with the login key', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'combined-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'combined-secret') + const authorize = vi.fn(() => true) + const { StorageImage, storageRoute } = createStorageImages({ + images, + public: ['website/'], + authorize, + }) + const publicUrl = imageUrl( + renderToStaticMarkup(), + ) + expect(parseSmartCdnUrl(publicUrl).auth).toBeUndefined() + const privateUrl = imageUrl( + renderToStaticMarkup(), + ) + expect(privateUrl).toMatch(/^\/api\/storage-images\?cap=/) + const response = await storageRoute(new Request(new URL(privateUrl, 'https://app.example'))) + expect(response.status).toBe(307) + const target = response.headers.get('location') + if (target === null) throw new Error('Expected authorized redirect') + expect(parseSmartCdnUrl(target).auth?.key).toBe('combined-key') + expect(parseSmartCdnUrl(target).template).toBe('builtin/storage-preview@0.0.2') + expect(new URL(target).searchParams.has('v')).toBe(false) + expect(authorize).toHaveBeenCalledOnce() + expect(response.headers.get('cache-control')).toBe('private, no-store') +}) + +test('the existing Smart CDN key pair remains a deliberate override', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'combined-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'combined-secret') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'render-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'render-secret') + const { StorageImage, storageRoute } = createStorageImages({ images, authorize: () => true }) + const src = imageUrl(renderToStaticMarkup()) + const target = (await storageRoute(new Request(new URL(src, 'https://app.example')))).headers.get( + 'location', + ) + if (target === null) throw new Error('Expected a redirect') + expect(parseSmartCdnUrl(target).auth?.key).toBe('render-key') +}) + +test('pinning a private Template does not replace the public Built-in', () => { + const { StorageImage } = createStorageImages({ + images, + public: ['website/'], + template: 'builtin/storage-preview@0.0.2', + }) + const url = imageUrl(renderToStaticMarkup()) + expect(parseSmartCdnUrl(url).template).toBe('builtin/public-preview@0.0.1') + expect(parseSmartCdnUrl(url).auth).toBeUndefined() +}) + +test('custom public and private Templates can be selected independently in a mixed factory', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'combined-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'combined-secret') + const { StorageImage, storageRoute } = createStorageImages({ + images, + public: ['website/'], + authorize: () => true, + template: 'private-preview', + publicTemplate: 'public-preview', + }) + const publicUrl = imageUrl( + renderToStaticMarkup(), + ) + expect(parseSmartCdnUrl(publicUrl).template).toBe('public-preview') + expect(parseSmartCdnUrl(publicUrl).auth).toBeUndefined() + const privateUrl = imageUrl( + renderToStaticMarkup(), + ) + const response = await storageRoute(new Request(new URL(privateUrl, 'https://app.example'))) + const target = response.headers.get('location') + if (target === null) throw new Error('Expected a signed private redirect') + expect(parseSmartCdnUrl(target).template).toBe('private-preview') + expect(parseSmartCdnUrl(target).auth?.key).toBe('combined-key') +}) + +test('private lifetime stays capped at 48 hours even in a mixed factory', () => { + expect(() => + createStorageImages({ images, public: ['website/'], authorize: () => true, lifetime: '365d' }), + ).toThrow(/48 hours/) +}) + +test('a denied unsigned development HEAD gives the publish command without blocking the render', async () => { + vi.stubEnv('NODE_ENV', 'development') + const fetch = vi.fn( + async () => + new Response(null, { status: 400, headers: { 'Transloadit-Error': 'NO_SIGNATURE_FIELD' } }), + ) + vi.stubGlobal('fetch', fetch) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + expect(renderToStaticMarkup()).toContain( + '', + ) + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('transloadit storage publish -- website/'), + ), + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"website/hero.jpg"')) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('no longer be under a published public prefix'), + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('remove its public prefix from the catalog or factory'), + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('configure private delivery with application authorization'), + ) + const url = fetch.mock.calls[0]?.[0] + expect(typeof url).toBe('string') + expect(String(url)).not.toContain('sig=') +}) + +test.each([ + { status: 400, code: undefined, hint: 'Check the delivery endpoint and Template' }, + { status: 400, code: 'INVALID_SIGNATURE', hint: 'Check the delivery endpoint and Template' }, + { status: 404, code: 'TEMPLATE_NOT_FOUND', hint: 'Check the workspace slug' }, + { status: 403, code: undefined, hint: 'Check the delivery endpoint and Template' }, +])('does not mistake HTTP $status ($code) for an unpublished prefix', async ({ + status, + code, + hint, +}) => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(null, { + status, + headers: code === undefined ? {} : { 'Transloadit-Error': code }, + }), + ), + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + renderToStaticMarkup() + await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining(hint))) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'https://my-app.tlcdn.com/builtin%2Fpublic-preview%400.0.1/website%2Fhero.jpg', + ), + ) + expect(warn.mock.calls.flat().join('\n')).not.toContain('transloadit storage publish') +}) + +test('identifies the verified public delivery target without logging query parameters', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(null, { + headers: { 'Content-Type': 'image/avif', 'Cache-Control': 'public, immutable' }, + }), + ), + ) + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + renderToStaticMarkup() + await vi.waitFor(() => + expect(info).toHaveBeenCalledWith( + expect.stringContaining( + 'https://my-app.tlcdn.com/builtin%2Fpublic-preview%400.0.1/website%2Fhero.jpg', + ), + ), + ) + expect(info.mock.calls.flat().join('\n')).not.toContain('?') +}) diff --git a/packages/img/test/size-diagnostics.test.tsx b/packages/img/test/size-diagnostics.test.tsx new file mode 100644 index 00000000..9662cdd5 --- /dev/null +++ b/packages/img/test/size-diagnostics.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, expect, test, vi } from 'vitest' + +import { ImageSizeDiagnostics } from '../src/next/ImageSizeDiagnostics.tsx' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +test.each([ + 0, 1, +])('does not warn about a temporary %ipx box before layout settles', async (width) => { + vi.useFakeTimers() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const container = document.createElement('div') + const root = createRoot(container) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + try { + await act(async () => + root.render( + + + + Canal house + + , + ), + ) + const image = container.querySelector('img') + if (image === null) throw new Error('Expected the scaffold image') + vi.spyOn(image, 'currentSrc', 'get').mockReturnValue('https://cdn.example/hero') + vi.spyOn(image, 'naturalWidth', 'get').mockReturnValue(960) + vi.spyOn(image, 'complete', 'get').mockReturnValue(true) + const geometry = vi + .spyOn(image, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, 0, width, 1)) + image.dispatchEvent(new Event('load')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).not.toHaveBeenCalled() + geometry.mockReturnValue(new DOMRect(0, 0, 960, 640)) + window.dispatchEvent(new Event('resize')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).not.toHaveBeenCalled() + } finally { + await act(async () => root.unmount()) + vi.unstubAllGlobals() + } +}) + +test.each([ + 'picture', + 'jpeg', +])('development warns about an oversized %s candidate without logging its private URL', async (source) => { + vi.useFakeTimers() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const container = document.createElement('div') + const root = createRoot(container) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + try { + await act(async () => + root.render( + + + + Hero + + , + ), + ) + const image = container.querySelector('img') + if (image === null) throw new Error('Expected a rendered image') + vi.spyOn(image, 'currentSrc', 'get').mockReturnValue( + source === 'picture' + ? 'https://cdn.example/image?secret=private' + : 'https://cdn.example/fallback.jpg?secret=private', + ) + const naturalWidth = vi.spyOn(image, 'naturalWidth', 'get').mockReturnValue(0) + const complete = vi.spyOn(image, 'complete', 'get').mockReturnValue(false) + vi.spyOn(image, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 200)) + window.dispatchEvent(new Event('resize')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).not.toHaveBeenCalled() + naturalWidth.mockReturnValue(1200) + complete.mockReturnValue(true) + image.dispatchEvent(new Event('load')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).toHaveBeenCalledExactlyOnceWith( + '[StorageImage] The selected 1200px candidate is more than twice its 300px rendered width. Set sizes to match the image’s CSS width.', + ) + image.dispatchEvent(new Event('load')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).toHaveBeenCalledTimes(1) + } finally { + await act(async () => root.unmount()) + vi.unstubAllGlobals() + } +}) + +test('a density-corrected or cached larger candidate does not imply missing sizes', async () => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const rootElement = document.createElement('div') + const root = createRoot(rootElement) + try { + await act(async () => + root.render( + + + + Canal house + + , + ), + ) + const image = rootElement.querySelector('img') + if (image === null) throw new Error('Expected the rendered hero') + vi.spyOn(image, 'currentSrc', 'get').mockReturnValue('https://cdn.example/hero') + vi.spyOn(image, 'complete', 'get').mockReturnValue(true) + vi.spyOn(image, 'naturalWidth', 'get').mockReturnValue(389) + vi.spyOn(image, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 374, 249)) + image.dispatchEvent(new Event('load')) + await act(() => vi.advanceTimersByTimeAsync(20)) + expect(warning).not.toHaveBeenCalled() + } finally { + await act(async () => root.unmount()) + vi.unstubAllGlobals() + } +}) diff --git a/packages/img/test/storage-factory.test.tsx b/packages/img/test/storage-factory.test.tsx new file mode 100644 index 00000000..3a005d2c --- /dev/null +++ b/packages/img/test/storage-factory.test.tsx @@ -0,0 +1,650 @@ +// @vitest-environment happy-dom + +import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server' +import { rgbaToThumbHash, thumbHashToDataURL } from 'thumbhash' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const { connection, builtin } = vi.hoisted(() => ({ + connection: vi.fn(async () => undefined), + builtin: { template: 'builtin/storage-preview@0.0.2' }, +})) + +vi.mock('next/server.js', () => ({ connection })) +vi.mock('server-only', () => ({})) +vi.mock('../src/index.ts', async (importOriginal) => ({ + ...(await importOriginal()), + get transloaditStoragePreviewTemplate() { + return builtin.template + }, +})) + +import { createStorageImages } from '../src/next/server.tsx' + +const images = { + 'website/hero.jpg': { path: 'website/hero.jpg', width: 2400, height: 1600 }, + 'logo.png': { path: 'logo.png', width: 64, height: 64 }, +} + +const thumbhash = Buffer.from(rgbaToThumbHash(1, 1, [45, 110, 160, 255])).toString('base64') + +test('the shortest valid ThumbHash from a narrow original still renders a blur', () => { + const pixels = new Uint8Array(100 * 4).fill(255) + const bytes = rgbaToThumbHash(1, 100, pixels) + expect(bytes).toHaveLength(17) + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(thumbHashToDataURL(bytes)) +}) + +test('public blur decodes the receipt on the server without changing image URLs or native attributes', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const src = { ...images['website/hero.jpg'], thumbhash } + const markup = renderToStaticMarkup( + , + ) + const doc = new DOMParser().parseFromString(markup, 'text/html') + const img = doc.querySelector('img') + expect(img?.style.backgroundImage).toContain(thumbHashToDataURL(Buffer.from(thumbhash, 'base64'))) + expect(img?.style.backgroundSize).toBe('100% 100%') + expect(img?.getAttribute('placeholder')).toBeNull() + expect(firstUrl(markup)).toEqual( + firstUrl(renderToStaticMarkup()), + ) +}) + +test.each([ + 'production', + 'development', +])('blur with no hash is a no-op, with a development-only note (%s)', (environment) => { + vi.stubEnv('NODE_ENV', environment) + vi.stubGlobal( + 'fetch', + vi.fn( + async () => new Response(null, { status: 307, headers: { Location: 'https://cdn.example' } }), + ), + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).not.toContain('data:image/') + if (environment === 'development') + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/website\/hero.jpg.*thumbhash.*storage store/), + ) + else expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + vi.unstubAllGlobals() + } +}) + +test('an alpha-encoded ThumbHash remains a no-op if a receipt omits hasAlpha', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const hash = Buffer.from(rgbaToThumbHash(1, 1, [45, 110, 160, 128])).toString('base64') + const markup = renderToStaticMarkup( + , + ) + expect(markup).not.toContain('data:image/') +}) + +test.each([ + 'contain', + 'none', + 'scale-down', +] as const)('blur cannot remain beside a letterboxed %s image', (objectFit) => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).not.toContain('data:image/') +}) + +test('a request-authorized private image never embeds its blurred pixels before authorization', () => { + const authorize = vi.fn(() => false) + const { StorageImage } = createStorageImages({ images, authorize }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).not.toContain('data:image/') + expect(markup).not.toContain(thumbhash) + expect(authorize).not.toHaveBeenCalled() +}) + +test.each([ + 'production', + 'development', +])('transparent images omit blur without adding client code (%s)', (environment) => { + vi.stubEnv('NODE_ENV', environment) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200 })), + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { StorageImage } = createStorageImages({ + images: { 'website/hero.jpg': { ...images['website/hero.jpg'], thumbhash, hasAlpha: true } }, + public: ['website/'], + }) + const markup = renderToStaticMarkup( + , + ) + const image = new DOMParser().parseFromString(markup, 'text/html').querySelector('img') + expect(image?.style.backgroundImage).toBe('') + expect(markup).not.toContain('data:image/') + expect(image?.getAttribute('onload')).toBeNull() + if (environment === 'development') + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('transparent image: no blur placeholder'), + ) + else expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + vi.unstubAllGlobals() + } +}) + +function firstUrl(markup: string): URL { + const document = new DOMParser().parseFromString(markup, 'text/html') + const src = document.querySelector('img')?.getAttribute('src') + if (src === null || src === undefined) throw new Error('Expected an image URL') + return new URL(src, 'https://app.example') +} + +function expiry(url: URL): number { + const expiresAt = parseSmartCdnUrl(url.href).auth?.expiresAt + if (expiresAt === undefined) throw new Error('Expected a signed expiry') + return expiresAt +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime('2029-01-01T12:00:00Z') + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'render-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'render-secret') + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'my-app') + builtin.template = 'builtin/storage-preview@0.0.2' + connection.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +test('the committed project catalog renders public images with no environment configuration', () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', undefined) + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', undefined) + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', undefined) + const catalog = { workspace: 'catalog-app', public: ['website/'], images } + const { StorageImage } = createStorageImages(catalog) + expect( + firstUrl(renderToStaticMarkup()).hostname, + ).toBe('catalog-app.tlcdn.com') + expect(connection).not.toHaveBeenCalled() +}) + +test('explicit factories also understand the catalog delivery block, with top-level overrides winning', () => { + const catalog = { + workspace: 'catalog-app', + public: ['website/'], + images, + delivery: { + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + } + const integration = createStorageImages(catalog) + const url = firstUrl( + renderToStaticMarkup(), + ) + expect(url.origin).toBe('http://127.0.0.1:32189') + expect(url.searchParams.get('cdn')).toBe('required') + const overridden = createStorageImages({ + ...catalog, + baseUrl: 'https://images.example/{workspace}', + }) + expect( + firstUrl(renderToStaticMarkup()) + .origin, + ).toBe('https://images.example') +}) + +test('workspace environment is an explicit override of committed project identity', () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'override-app') + const { StorageImage } = createStorageImages({ + workspace: 'catalog-app', + public: ['website/'], + images, + }) + expect( + firstUrl(renderToStaticMarkup()).hostname, + ).toBe('override-app.tlcdn.com') +}) + +test('width is constrained by default and priority reserves an eager high-priority preload', () => { + const { StorageImage } = createStorageImages({ + workspace: 'my-app', + public: ['website/'], + images, + }) + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + 'text/html', + ) + const image = document.querySelector('img') + expect(image?.style.maxWidth).toBe('960px') + expect(image?.style.width).toBe('100%') + expect(image?.getAttribute('loading')).toBe('eager') + expect(image?.getAttribute('fetchpriority')).toBe('high') + expect(document.querySelector('link[rel="preload"]')?.getAttribute('fetchpriority')).toBe('high') + expect(document.querySelector('source')?.getAttribute('sizes')).toBe( + '(min-width: 960px) 960px, 100vw', + ) +}) + +test('the default width never enlarges a small receipt', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('max-width:320px') + expect(markup).toContain('width="320"') + expect(markup).toContain('height="240"') +}) + +test('duration strings and millisecond aliases issue identical capabilities and redirects', async () => { + const short = createStorageImages({ + images, + authorize: () => true, + cacheMaxAge: '1m', + rotationInterval: '10m', + lifetime: '1h', + }) + const legacy = createStorageImages({ + images, + authorize: () => true, + cacheMaxAgeMs: 60_000, + rotationIntervalMs: 600_000, + lifetime: 3_600_000, + }) + const url = firstUrl( + renderToStaticMarkup(), + ) + expect( + firstUrl(renderToStaticMarkup()), + ).toEqual(url) + const response = await short.storageRoute(new Request(url)) + expect(response.headers.get('cache-control')).toBe('private, max-age=60') + expect(response.headers.get('location')).toBe( + (await legacy.storageRoute(new Request(url))).headers.get('location'), + ) +}) + +test('art direction derives a responsive box and permits an externally owned fill box', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const aspectRatio = { '(max-width: 639px)': '9/16', default: '16/9' } + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('aspect-ratio:1.7777777777777777') + expect(markup).toContain('@media (max-width: 639px)') + expect(markup).toContain('aspect-ratio:0.5625') + const external = renderToStaticMarkup( + , + ) + expect(external).not.toContain('aspect-ratio') +}) + +test('requires an explicit delivery choice, naming all three alternatives', () => { + expect(() => createStorageImages({ images })).toThrow( + /Choose public, authorize, or delivery: 'direct'/, + ) +}) + +test('a recovered private catalog explains intentional publication without guessing public access', () => { + expect(() => createStorageImages({ workspace: 'my-app', public: [], images })).toThrow( + /No public prefixes.*storage publish.*authorize/s, + ) + expect(connection).not.toHaveBeenCalled() +}) + +test('one factory accepts explicit credentials and retains the redirect overload', async () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', undefined) + const { StorageImage, storageRoute } = createStorageImages({ + images, + authKey: 'explicit-key', + authSecret: 'explicit-secret', + workspace: 'explicit-app', + authorize: () => true, + }) + const url = firstUrl(renderToStaticMarkup()) + const location = (await storageRoute(new Request(url))).headers.get('location') + expect(location).not.toBeNull() + expect(new URL(location ?? '').hostname).toBe('explicit-app.tlcdn.com') +}) + +test('exports only the single Next.js factory, not the unpublished aliases', async () => { + const exports = await import('../src/next/server.tsx') + expect(Object.keys(exports)).toEqual(['createStorageImages']) +}) + +test('public catalog images are static with unsigned direct URLs and no signing shell', () => { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + , + ) + const url = firstUrl(markup) + expect(url.hostname).toBe('my-app.tlcdn.com') + expect(parseSmartCdnUrl(url.href).auth).toBeUndefined() + expect(markup).not.toContain('visibility:hidden') + expect(connection).not.toHaveBeenCalled() + expect(markup).toContain('max-width:960px') +}) + +test('declared public images never expire or emit a dynamic-delivery warning', () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { headers: { 'Content-Type': 'image/png' } })), + ) + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + try { + const { StorageImage } = createStorageImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup() + expect(parseSmartCdnUrl(firstUrl(markup).href).auth).toBeUndefined() + expect(info).not.toHaveBeenCalled() + } finally { + info.mockRestore() + vi.unstubAllGlobals() + } +}) + +test('catalog directories accept DB receipts, but root entries do not authorize the workspace', () => { + const { StorageImage } = createStorageImages({ images, authorize: () => true }) + expect(renderToStaticMarkup()).toContain('width="64"') + expect( + renderToStaticMarkup( + , + ), + ).toContain('width="300"') + expect(() => + StorageImage({ src: { path: 'private.jpg', width: 20, height: 20 }, alt: 'Private' }), + ).toThrow(/allowed/) +}) + +test('explicit scope still limits catalog paths and public declarations', () => { + const { StorageImage } = createStorageImages({ + images, + allowedPathPrefixes: [], + delivery: 'direct', + }) + expect(() => StorageImage({ src: 'website/hero.jpg', alt: 'Denied' })).toThrow(/allowed/) + expect(() => + createStorageImages({ images, allowedPathPrefixes: ['website/'], public: ['private/'] }), + ).toThrow(/public.*allowed/) +}) + +test('catalog keys must agree with their receipt paths and unknown keys never fall through', () => { + expect(() => + createStorageImages({ images: { 'public.jpg': images['website/hero.jpg'] } }), + ).toThrow(/catalog.*path/) + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(StorageImage, undefined, [ + { src: 'website/typo.jpg', alt: 'Typo', width: 300, height: 200 }, + ]), + ).toThrow(/catalog/) +}) + +test('a missing catalog path names the typo, nearest key and safe upload command', () => { + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: 'website/herp.jpg', alt: 'Hero' }]), + ).toThrow(/Storage image path "website\/herp.jpg".*Did you mean "website\/hero.jpg"/) + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: 'website/herp.jpg', alt: 'Hero' }]), + ).toThrow('npx transloadit storage store -- ./image.jpg website/herp.jpg') + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: "-$(whoami)'photo.jpg", alt: 'Unknown' }]), + ).toThrow("transloadit storage store -- ./image.jpg '-$(whoami)'\\''photo.jpg'") +}) + +test('an empty catalog names the missing path without inventing a suggestion', () => { + const { StorageImage } = createStorageImages({ images: {}, delivery: 'direct' }) + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: 'website/new.jpg', alt: 'New' }]), + ).toThrow(/Storage image path "website\/new.jpg".*To upload a new image/) +}) + +test('a custom-catalog recovery hint explains where receipts must be written', () => { + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: 'website/new.jpg', alt: 'New' }]), + ).toThrow('For a custom catalog, add --receipts to the command') + expect(() => + Reflect.apply(StorageImage, undefined, [{ src: 'website/new.jpg', alt: 'New' }]), + ).toThrow('For an explicit factory, update its images configuration too') +}) + +test.each([ + '/website/hero.jpg', + 'website/hero.jpg ', + ' website/hero.jpg', +])('an invalid formatting variant %j suggests the exact catalog key without upload advice', (path) => { + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + const render = () => Reflect.apply(StorageImage, undefined, [{ src: path, alt: 'Hero' }]) + expect(render).toThrow(`Storage image path ${JSON.stringify(path)} is invalid`) + expect(render).toThrow('Did you mean "website/hero.jpg"? Use the exact catalog key') + expect(render).not.toThrow('storage store') +}) + +test.each([ + { name: 'terminal controls', path: 'website/\u001b[2J.jpg' }, + { name: 'newlines', path: 'website/new\nline.jpg' }, + { name: 'oversized paths', path: `${'a'.repeat(1025)}.jpg` }, +])('rejects $name before formatting unknown-path shell advice', ({ path }) => { + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + const render = () => Reflect.apply(StorageImage, undefined, [{ src: path, alt: 'Invalid' }]) + expect(render).toThrow(/Storage image paths must/) + expect(render).not.toThrow(path) + expect(render).not.toThrow('storage store') +}) + +test('workspace-root access requires the named acknowledgment, never an empty prefix', () => { + expect(() => createStorageImages({ allowedPathPrefixes: [''] })).toThrow(/allowWorkspaceRoot/) + const { StorageImage } = createStorageImages({ + allowWorkspaceRoot: true, + authorize: () => true, + }) + expect(renderToStaticMarkup()).toContain( + '', + ) +}) + +test.each([ + 'constructor', + 'toString', + '__proto__', +])('requires an own catalog entry for %s without excluding an explicitly stored file', (path) => { + const missing = createStorageImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(missing.StorageImage, undefined, [{ src: path, alt: 'Missing image' }]), + ).toThrow(`Storage image path "${path}" is not in the configured catalog`) + const present = createStorageImages({ + images: { [path]: { path, width: 64, height: 64 } }, + authorize: () => true, + }) + expect(renderToStaticMarkup()).toContain( + 'width="64"', + ) +}) + +test.each([ + 30_001, 59_999, 60_000, +])('rejects a rotation interval of %i that leaves less than half the lifetime for delivery', (rotationIntervalMs) => { + expect(() => + createStorageImages({ + images, + authorize: () => true, + lifetime: 60_000, + rotationIntervalMs, + }), + ).toThrow(/rotationIntervalMs.*half/) + expect(() => + createStorageImages({ images, public: ['website/'], lifetime: 60_000, rotationIntervalMs }), + ).toThrow(/rotationIntervalMs.*half/) +}) + +test('rotation margin also applies at the private cap in a mixed public factory', () => { + expect(() => + createStorageImages({ + images, + authorize: () => true, + public: ['website/'], + lifetime: '2d', + rotationIntervalMs: 24 * 3_600_000 + 1, + }), + ).toThrow(/rotationIntervalMs.*half/) +}) + +test.each([ + 0, 1, 29_999, 30_000, 59_999, 60_000, +])('lifetime bounds an issued grant at offset %i', async (offset) => { + const { StorageImage, storageRoute } = createStorageImages({ + images, + authorize: () => true, + lifetime: 60_000, + }) + const url = firstUrl(renderToStaticMarkup()) + vi.setSystemTime(Date.now() + offset) + const response = await storageRoute(new Request(url)) + const location = response.headers.get('location') + if (location === null) throw new Error('Expected a redirect') + const remaining = expiry(new URL(location)) - Date.now() + expect(remaining).toBeGreaterThanOrEqual(30_000) + expect(remaining).toBeLessThanOrEqual(60_000) +}) + +test('an explicit half-lifetime rotation retains its margin just before the boundary', async () => { + const { StorageImage, storageRoute } = createStorageImages({ + images, + authorize: () => true, + lifetime: 60_000, + rotationIntervalMs: 30_000, + }) + const url = firstUrl(renderToStaticMarkup()) + vi.setSystemTime(Date.now() + 29_999) + const location = (await storageRoute(new Request(url))).headers.get('location') + if (location === null) throw new Error('Expected a redirect') + expect(expiry(new URL(location)) - Date.now()).toBe(30_001) +}) + +test('mixed public factories never lengthen private grants beyond 48 hours', async () => { + const { StorageImage, storageRoute } = createStorageImages({ + images, + authorize: () => true, + public: ['website/'], + lifetime: '2d', + }) + const url = firstUrl(renderToStaticMarkup()) + const location = (await storageRoute(new Request(url))).headers.get('location') + if (location === null) throw new Error('Expected a redirect') + expect(expiry(new URL(location)) - Date.now()).toBeLessThanOrEqual(48 * 3_600_000) + expect(() => createStorageImages({ images, authorize: () => true, lifetime: '365d' })).toThrow( + /48 hours/, + ) +}) + +test('a Built-in bump preserves old markup and signs with the new Built-in', async () => { + const configuration = { images, authorize: vi.fn(() => true) } + const old = createStorageImages(configuration) + const url = firstUrl(renderToStaticMarkup()) + builtin.template = 'builtin/storage-preview@0.0.3' + const current = createStorageImages(configuration) + const response = await current.storageRoute(new Request(url)) + expect(response.status).toBe(307) + expect(configuration.authorize).toHaveBeenCalledOnce() + const location = response.headers.get('location') + if (location === null) throw new Error('Expected a redirect') + expect(parseSmartCdnUrl(location).template).toBe('builtin/storage-preview@0.0.3') + const custom = createStorageImages({ ...configuration, template: 'my-custom-preview' }) + expect((await custom.storageRoute(new Request(url))).status).toBe(404) +}) + +test('direct factory imports need no credentials; first use validates them lazily', async () => { + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', undefined) + const { StorageImage } = createStorageImages({ images, delivery: 'direct' }) + const onError = vi.fn() + const stream = await renderToReadableStream(, { + onError, + }) + await stream.allReady + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('TRANSLOADIT_SMART_CDN_SECRET') }), + expect.anything(), + ) + expect(connection).toHaveBeenCalled() +}) + +test('explicit credentials use the same flat catalog configuration', () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', undefined) + const { StorageImage } = createStorageImages({ + authKey: 'explicit-key', + authSecret: 'explicit-secret', + workspace: 'explicit-app', + images, + public: ['website/'], + }) + expect( + firstUrl(renderToStaticMarkup()).hostname, + ).toBe('explicit-app.tlcdn.com') +}) diff --git a/packages/img/test/types.tsx b/packages/img/test/types.tsx index 9f52ca55..45ab1011 100644 --- a/packages/img/test/types.tsx +++ b/packages/img/test/types.tsx @@ -1,4 +1,4 @@ -import type { TransloaditImageModelOptions } from '../src/index.ts' +import type { TransloaditImageModelOptions, TransloaditImageSource } from '../src/index.ts' import type { TransloaditImageComponent, TransloaditImageIntegration, @@ -7,21 +7,25 @@ import type { } from '../src/next/server.tsx' import { createTransloaditImageModel } from '../src/index.ts' +import { TransloaditPicture } from '../src/next/index.tsx' +import { createStorageImages } from '../src/next/server.tsx' const modelOptions: TransloaditImageModelOptions = { expiresAt: Date.UTC(2030, 0, 1), + fallbackBackground: '#ffffff', formats: { avif: 45, webp: 75 }, height: 300, src: 'documents/report.pdf', width: 400, } -const imageProps: TransloaditImageProps = { +const imageProps = { alt: 'Preview of report.pdf', + fallbackBackground: '#224466', height: 300, src: 'documents/report.pdf', width: 400, -} +} satisfies TransloaditImageProps // @ts-expect-error Storage preview formats use format-specific quality values, not a tuple. const modelWithTuple: TransloaditImageModelOptions = { ...modelOptions, formats: ['webp'] } @@ -42,17 +46,272 @@ declare const Image: TransloaditImageComponent declare const direct: TransloaditImageIntegration declare const redirect: TransloaditRedirectImageIntegration const model = createTransloaditImageModel(modelOptions, () => '') +// @ts-expect-error Images are immediately browser-discoverable; hydration deferral was removed. +const deferredImage = +const gatedPicture = ( + // @ts-expect-error Art direction is modeled with aspectRatio, not viewport activation props. + +) +const placeholderPicture = ( + +) +void deferredImage +void gatedPicture +void placeholderPicture const image = Image(imageProps) -const directImage = direct.Image(imageProps) -const redirectedImage = redirect.Image(imageProps) +const directImage = direct.StorageImage(imageProps) +const redirectedImage = redirect.StorageImage(imageProps) const routeResponse = redirect.storageRoute(new Request('https://app.example/images')) +const attributedImage = ( + +) +const eagerImage = +const preloadedImage = +// @ts-expect-error A preloaded image cannot also be lazy. +const lazyPreloadedImage = +void preloadedImage +void lazyPreloadedImage +const lazyImage = +const receipt = { + path: 'documents/report.pdf', + width: 400, + height: 300, +} satisfies TransloaditImageSource +const receiptImage = Receipt +const receiptRedirect = +const receiptModel = createTransloaditImageModel( + { expiresAt: modelOptions.expiresAt, src: receipt }, + () => '', +) +// @ts-expect-error A string source still requires its source dimensions. +const missingDimensions = Incomplete +const duplicateDimensions = Presentation box +createTransloaditImageModel( + // @ts-expect-error The neutral model has the same exclusive source geometry contract. + { expiresAt: modelOptions.expiresAt, src: receipt, width: 400, height: 300 }, + () => '', +) +// @ts-expect-error A receipt does not weaken the lazy/preload union. +const lazyReceiptPreload = Receipt +const receiptRedirectFallback = ( + // @ts-expect-error A receipt does not give redirect delivery a Suspense fallback. + +) +// @ts-expect-error A preloaded image cannot be lazy. +const lazyPreload = +const lazyPicturePreload = ( + // @ts-expect-error The model-only renderer also rejects a lazy preload. + +) +// @ts-expect-error A redirect image never suspends for signing. +const redirectFallback = +// @ts-expect-error Event callbacks are not serializable image attributes. +const callbackImage = undefined} /> +// @ts-expect-error Candidate URLs belong to the configured image model. +const customSourceSet = +// @ts-expect-error Signing policy belongs to the server-only factory. +const perImageSecret = +const configuredRedirect = createStorageImages({ + authKey: 'key', + authSecret: 'secret', + workspace: 'app', + allowedPathPrefixes: [], + route: '/images', + authorize: () => true, +}) +const configuredRedirectFallback = ( + // @ts-expect-error Factory overloads retain the redirect-specific component contract. + +) +const envDirect = createStorageImages({ + allowedPathPrefixes: ['documents/'], + delivery: 'direct', +}) +const images = { + 'website/hero.jpg': { path: 'website/hero.jpg', width: 2400, height: 1600 }, +} +const catalog = createStorageImages({ images, public: ['website/'] }) +const catalogHero = ( + +) +const catalogAvatar = ( + +) +const catalogFill = ( + +) +const catalogReceipt = +// @ts-expect-error Catalog references are exact keys, not unchecked paths. +const catalogTypo = +const catalogUnknown = ( + // @ts-expect-error Explicit geometry does not bypass the catalog-key contract. + +) +const privateCatalog = createStorageImages({ images, authorize: () => true }) +// @ts-expect-error The private factory retains the same exact catalog keys. +const privateTypo = +void [ + catalogHero, + catalogAvatar, + catalogFill, + catalogReceipt, + catalogTypo, + catalogUnknown, + privateTypo, +] +// @ts-expect-error Callers must explicitly choose the allowed prefixes, including deny-all []. +createStorageImages({}) +const namedStorageImage = +void namedStorageImage +const fixedImage = ( + +) +const constrainedImage = ( + +) +const fillImage = ( + +) +const privateIntegration = createStorageImages({ + allowedPathPrefixes: ['documents/'], + authorize: ({ path, request }) => path.endsWith('.pdf') && request.method === 'GET', + lifetime: 60_000, + public: ['documents/public/'], +}) +const artDirectedImage = ( + +) +const incompleteArtDirection = ( + Missing default +) +const nonCroppingArtDirection = ( + // @ts-expect-error Breakpoint crops require cover, not contain. + Contain +) +// @ts-expect-error Only an authorize callback opts into a redirect handler. +void envDirect.storageRoute +// @ts-expect-error The unpublished Image alias was removed. +void envDirect.Image +void artDirectedImage +void incompleteArtDirection +void nonCroppingArtDirection +const incompleteFixed = ( + // @ts-expect-error Fixed layout needs both display-box dimensions. + +) +const incompleteConstrained = ( + +) +void fixedImage +void constrainedImage +void fillImage +void incompleteFixed +void incompleteConstrained +const missingFillRatio = ( + // @ts-expect-error A fill crop needs the container ratio; it cannot be inferred from the source. + +) +const fixedString = ( + // @ts-expect-error Fixed width and height describe the box, so the source must carry its dimensions. + +) +void missingFillRatio +void fixedString +const envRedirect = createStorageImages({ + allowedPathPrefixes: ['documents/'], + route: '/images', + authorize: () => true, +}) +const envImage = ( + +) +const envRedirectImage = +const envRoute = envRedirect.storageRoute(new Request('https://app.example/images')) +// @ts-expect-error The environment helper requires explicit Storage policy, not guessed access. +createStorageImages({}) +// @ts-expect-error Direct delivery does not expose an authorization route. +const envDirectRoute = envDirect.storageRoute +const envLazyPreload = ( + // @ts-expect-error The env factory preserves the lazy/priority union. + +) +const envRedirectFallback = ( + // @ts-expect-error Redirect delivery has no signing suspension to replace. + +) // @ts-expect-error Direct integrations do not expose an authorization route. const missingRoute = direct.storageRoute // @ts-expect-error Storage previews always use their signed JPEG fallback. const imageWithFallback = // @ts-expect-error Storage previews do not support viewport-conditional activation. const imageWithMedia = -// @ts-expect-error Storage-only sources are relative object paths, not discriminated objects. +// @ts-expect-error An object source needs a path and source dimensions, not a storage discriminator. const imageWithObjectSource = void directImage @@ -65,3 +324,26 @@ void model void modelWithTuple void redirectedImage void routeResponse +void attributedImage +void eagerImage +void lazyImage +void lazyPreload +void lazyPicturePreload +void redirectFallback +void callbackImage +void customSourceSet +void perImageSecret +void configuredRedirectFallback +void receiptImage +void receiptRedirect +void receiptModel +void missingDimensions +void duplicateDimensions +void lazyReceiptPreload +void receiptRedirectFallback +void envImage +void envRedirectImage +void envRoute +void envDirectRoute +void envLazyPreload +void envRedirectFallback diff --git a/packages/node/README.md b/packages/node/README.md index 0daaa0aa..a7f06fc3 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -24,7 +24,7 @@ This is a **Node.js** SDK to make it easy to talk to the ## Requirements -- [Node.js](https://nodejs.org/en/) version 20 or newer +- [Node.js](https://nodejs.org/en/) version 20.10.0 or newer - [A Transloadit account](https://transloadit.com/signup/) ([free signup](https://transloadit.com/pricing/)) - [Your API credentials](https://transloadit.com/c/template-credentials) (`authKey`, `authSecret`) @@ -107,6 +107,72 @@ Most commands can authenticate with either `TRANSLOADIT_AUTH_TOKEN` or `TRANSLOA `TRANSLOADIT_SECRET`. Commands that mint bearer tokens or generate signatures still require `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET`. +### Storage images for Next.js + +`@transloadit/img` is currently an unpublished, private preview. Follow the +[local package instructions](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md) +until release. In a Next.js 16.3.3+ App Router project with Storage enabled, run: + +```bash +yarn transloadit auth login +yarn transloadit storage store ./hero.jpg website/hero.jpg --public --hashed +yarn dev +``` + +Follow the image package Quickstart to add its Next plugin and import StorageImage from the package. +`--hashed` creates an immutable filename; use the printed JSX path, including its content hash. +Start with `auth login` even without an account: choose Sign up in the browser it opens, then approve +the CLI. For application-server uploads instead of CLI seeding, see [Store an image](#store-an-image). +Login opens browser approval (on Windows, open the printed URL) and saves one combined Auth Key, +workspace and signing algorithm in the owner-only credentials file. `--no-browser` prints the +approval URL; `--stdin` accepts dotenv credentials for automation. Existing credentials require +`--replace`. Login also checks Storage policy access with a bounded read-only request; if that +fails, it saves the login and prints a Console link. This check does not prove upload availability. + +Store creates `transloadit.images.json` and derived `transloadit-images.d.ts`; commit both. The +catalog carries workspace, public prefixes and image receipts: no public app env is needed. +`--public` declares the destination directory recursively, including future uploads, after saving +the receipt. Plain store never publishes. `image init website/ --example` is an optional example +generator; private init (`image init uploads/ --private`) creates `transloadit.authorize.ts` and a +route that denies access until you connect per-object authorization. Use a separate deployment key. +Existing code/env files are never overwritten. Missing trailing directory slashes are accepted. + +Store uploads originals and appends validated receipts to `transloadit.images.json`; commit it. +`storage store ./images/*.jpg website/` stores shell-expanded files, checkpointing each success. +It prints `width={960}` (bounded by the original) and `placeholder="blur"`, with a filename-derived +alt and a reminder. Store generates an optional base64 `thumbhash` from the original bytes. +An occupied path conflicts unless `--overwrite` is explicit; prefer `--hashed` for immutable +filenames. Matching receipts skip repeat uploads; changed bytes get a new name. +The public image `v` is a cache-busting tag derived from the receipt hash; the origin does not verify +it, so a cold request after an overwrite can return the replacement. +Publication can also be managed explicitly: + +```bash +yarn transloadit storage publish website/ +yarn transloadit storage publications +yarn transloadit storage unpublish website/ +yarn transloadit auth status +yarn transloadit auth logout +``` + +Logout revokes browser-login keys, including their use by any application. Imported (`--stdin`) +and legacy keys are only forgotten locally; `auth logout --revoke` explicitly revokes those too. + +Unpublishing stops origin access but cannot recall cached/downloaded bytes. For lost metadata, +restore the committed catalog. Listing and sync need the Storage read API, not yet enabled in +production; see [Recovery options and prerequisites](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#recovery-requires-the-storage-read-api-not-yet-enabled-in-production). + +Storage commands report the selected credential source on stderr before operating. Ordinary +commands retain shell → project `.env` → saved login precedence; init prefers the saved login. +Store, list, sync and publication verify the selected key against the catalog workspace and stop +before acting on a mismatch. An explicit `--workspace` opts out, but never mixes catalogs: use +`--receipts` for a separate workspace's catalog. Status shows the saved workspace/key description; +logout revokes that key before deleting the credentials file and preserves it if revocation fails. +The endpoint stays bound to those credentials unless explicitly overridden. JSON result output +stays on stdout; credentials are never printed. See the +[image Quickstart](https://github.com/transloadit/node-sdk/blob/main/packages/img/README.md) +and [reference](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md). + ### Minting Bearer Tokens (Hosted MCP) If you want to connect an agent to the Transloadit-hosted MCP endpoint, mint a short-lived bearer @@ -1379,7 +1445,48 @@ npx transloadit assemblies list -l 7 ## SDK Usage -The following code will upload an image and resize it to a thumbnail: +### Store an image + +`storeImage()` uploads one local image and returns a verified receipt ready for `StorageImage`. +Install `@transloadit/node` as a runtime dependency for server uploads; a dev dependency is enough +when you only use its CLI. Use an Assembly-enabled application key in your server environment, +not a signing-only image-delivery key or the disposable login key that `auth logout` revokes. +New combined Smart CDN/Assembly keys use SHA-256, as selected below; use your configured algorithm +for an existing key. + +In an authenticated server handler, `user` is your verified session user and `saveImage` is your +application's database helper, not an SDK function: + +```ts +import { randomUUID } from 'node:crypto' +import { Transloadit } from '@transloadit/node' + +const { TRANSLOADIT_KEY: authKey, TRANSLOADIT_SECRET: authSecret } = process.env +if (!authKey || !authSecret) { + throw new Error('Set TRANSLOADIT_KEY and TRANSLOADIT_SECRET on the server') +} +const client = new Transloadit({ + authKey, + authSecret, + signatureAlgorithm: 'sha256', +}) +const receipt = await client.storeImage('./hero.jpg', { + path: `uploads/${randomUUID()}/hero.jpg`, +}) +await saveImage({ ...receipt, ownerId: user.id }) +``` + +Choose the destination on the server and save the complete receipt with its owner ID. An occupied +path is refused unless you explicitly pass `overwrite: true`. This helper does not publish a +directory or update the CLI's catalog. Pass the saved receipt as `src` in an authorized application; +see [user uploads, private access and trusted receipt recovery with `getStoredImageReceipt()`](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#images-uploaded-by-your-users) +for the Uppy/notification flow and recovery without another upload. + +### Process an image + +The following code will upload an image and resize it to a thumbnail. New Console-created combined +Smart CDN/Assembly keys use SHA-256, so this example selects it explicitly; the SDK's legacy default +remains SHA-384 for existing keys. ```javascript import { Transloadit } from '@transloadit/node' @@ -1387,6 +1494,7 @@ import { Transloadit } from '@transloadit/node' const transloadit = new Transloadit({ authKey: 'YOUR_TRANSLOADIT_KEY', authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', }) try { @@ -1486,10 +1594,24 @@ Table of contents: Returns a new instance of the client. +```typescript +const transloadit = new Transloadit({ + authKey: 'YOUR_TRANSLOADIT_KEY', + authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', +}) +``` + +Select SHA-256 for new Console-created combined Smart CDN/Assembly keys; omitting it retains +the SHA-384 default for existing keys and can produce `INVALID_SIGNATURE` with a combined key. + The `options` object can contain the following keys: - `authKey` **(required)** - see [requirements](#requirements) - `authSecret` **(required)** - see [requirements](#requirements) +- `signatureAlgorithm` (default `'sha384'`) - API signing algorithm configured on this Auth Key. + Use `'sha256'` for combined Smart CDN-enabled keys. Browser CLI login saves and applies this + setting automatically; explicit `calcSignature(params, algorithm)` calls still override it. - `endpoint` (default `'https://api2.transloadit.com'`) - `maxRetries` (default `5`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) - `gotRetry` (default `0`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) @@ -1934,8 +2056,3 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo ## Development See [CONTRIBUTING](./CONTRIBUTING.md). - - - - - diff --git a/packages/node/package.json b/packages/node/package.json index b7341482..22b34b20 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -29,15 +29,17 @@ "author": "Tim Koschuetzki ", "packageManager": "yarn@4.12.0", "engines": { - "node": ">= 20" + "node": ">= 20.10.0" }, "dependencies": { + "@aws-sdk/client-s3": "3.1073.0", "@transloadit/sev-logger": "^0.1.9", "@transloadit/utils": "^4.8.1", "cacheable-lookup": "^7.0.0", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.3", "dotenv": "^17.4.2", + "execa": "9.6.1", "form-data": "^4.0.5", "got": "14.6.6", "into-stream": "^9.1.0", @@ -48,18 +50,21 @@ "p-map": "^7.0.4", "p-queue": "^9.3.0", "recursive-readdir": "^2.2.3", + "thumbhash": "0.1.1", "tus-js-client": "^4.3.1", "typanion": "^3.14.0", "type-fest": "^5.6.0", "zod": "3.25.76" }, + "optionalDependencies": { + "sharp": "0.35.4" + }, "devDependencies": { "@types/debug": "^4.1.13", "@types/lodash-es": "^4.17.12", "@types/node": "^25.8.0", "@types/recursive-readdir": "^2.2.4", "badge-maker": "^5.0.2", - "execa": "9.6.1", "image-size": "^2.0.2", "nock": "^14.0.15", "p-retry": "^8.0.0", diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index af7bb771..9c005b2a 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -4,7 +4,9 @@ import type { CompileAssemblyInstructionsOptions, CompileAssemblyInstructionsResult, } from '@transloadit/utils' +import type { SignatureAlgorithm } from '@transloadit/utils/node' import type { Delays, Headers, OptionsOfJSONResponseBody, RetryOptions } from 'got' +import type { Input as IntoStreamInput } from 'into-stream' import type { TransloaditErrorResponseBody } from './ApiError.ts' import type { @@ -38,6 +40,16 @@ import type { LintAssemblyInstructionsInput, LintAssemblyInstructionsResult, } from './lintAssemblyInstructions.ts' +import type { + GetStoredImageReceiptOptions, + StoredImageReceipt, + StoreImageOptions, +} from './storageImage.ts' +import type { + StoragePublicPrefixDeclared, + StoragePublicPrefixes, + StoragePublicPrefixRevoked, +} from './storagePublicPrefixes.ts' import type { Stream, UploadBehavior } from './tus.ts' import * as assert from 'node:assert' @@ -52,9 +64,10 @@ import { getSignedSmartCdnUrl, signParamsSync } from '@transloadit/utils/node' import debug from 'debug' import FormData from 'form-data' import got, { HTTPError, RequestError } from 'got' -import intoStream, { type Input as IntoStreamInput } from 'into-stream' +import intoStream from 'into-stream' import { isReadableStream, isStream } from 'is-stream' import pMap from 'p-map' +import { z } from 'zod' import packageJson from '../package.json' with { type: 'json' } import { ApiError } from './ApiError.ts' @@ -65,6 +78,13 @@ import InconsistentResponseError from './InconsistentResponseError.ts' import { lintAssemblyInstructions as lintAssemblyInstructionsInternal } from './lintAssemblyInstructions.ts' import PaginationStream from './PaginationStream.ts' import PollingTimeoutError from './PollingTimeoutError.ts' +import { getStoredImageReceipt, storeImage } from './storageImage.ts' +import { + normalizeStoragePublicPrefix, + storagePublicPrefixDeclaredSchema, + storagePublicPrefixesSchema, + storagePublicPrefixRevokedSchema, +} from './storagePublicPrefixes.ts' import { sendTusRequest } from './tus.ts' export type { @@ -93,6 +113,17 @@ export type { RobotListResult, RobotParamHelp, } from './robots.ts' +export type { + GetStoredImageReceiptOptions, + StoredImageExpectation, + StoredImageReceipt, + StoreImageOptions, +} from './storageImage.ts' +export type { + StoragePublicPrefixDeclared, + StoragePublicPrefixes, + StoragePublicPrefixRevoked, +} from './storagePublicPrefixes.ts' export { buildCompileAssemblyInstructionsSystemPrompt, @@ -382,6 +413,8 @@ type AuthToken = { } type BaseOptions = { + /** Use signatureAlgorithm: 'sha256' for new combined Smart CDN/Assembly keys; legacy default: sha384. */ + signatureAlgorithm?: SignatureAlgorithm endpoint?: string maxRetries?: number timeout?: number @@ -393,6 +426,7 @@ type BaseOptions = { export type Options = BaseOptions & (AuthKeySecret | AuthToken) export class Transloadit { + #signatureAlgorithm: SignatureAlgorithm private _authKey: string private _authSecret: string @@ -413,6 +447,7 @@ export class Transloadit { private _validateResponses = false + /** Create a client; new combined keys require signatureAlgorithm: 'sha256' explicitly. */ constructor(opts: Options) { const rawToken = typeof opts?.authToken === 'string' ? opts.authToken.trim() : '' const hasToken = rawToken.length > 0 @@ -433,6 +468,7 @@ export class Transloadit { this._authKey = opts.authKey ?? '' this._authSecret = opts.authSecret ?? '' + this.#signatureAlgorithm = opts.signatureAlgorithm ?? 'sha384' this._authToken = hasToken ? rawToken : null this._endpoint = opts.endpoint || 'https://api2.transloadit.com' this._maxRetries = opts.maxRetries != null ? opts.maxRetries : 5 @@ -453,6 +489,16 @@ export class Transloadit { this._defaultTimeout = timeout } + /** Stores one local original at an explicit path and returns its verified image metadata. */ + storeImage(filePath: string, options: StoreImageOptions): Promise { + return storeImage(this, filePath, options) + } + + /** Reconstructs a verified receipt from authoritative Assembly status and trusted upload facts. */ + getStoredImageReceipt(options: GetStoredImageReceiptOptions): Promise { + return getStoredImageReceipt(this, options) + } + /** * Create an Assembly * @@ -1199,6 +1245,16 @@ export class Transloadit { }) } + /** Revoke the signing Auth Key itself, without granting access to other workspace keys. */ + async revokeOwnAuthKey(): Promise { + const result = await this._remoteJson({ + urlSuffix: '/auth_keys/self', + method: 'delete', + }) + checkResult(result) + z.object({ ok: z.literal('AUTH_KEY_DELETED') }).parse(result) + } + /** * Get an Assembly Template * @@ -1232,6 +1288,49 @@ export class Transloadit { return new PaginationStream(async (page) => this.listTemplates({ ...params, page })) } + /** Declare a directory public for unsigned Storage Built-ins. Requires dam:write scope. */ + async publishStoragePrefix( + prefix: string, + options?: { signal?: AbortSignal }, + ): Promise { + const result = await this._remoteJson({ + urlSuffix: '/storage/public_prefixes', + method: 'post', + params: { prefix: normalizeStoragePublicPrefix(prefix) }, + signal: options?.signal, + }) + checkResult(result) + return storagePublicPrefixDeclaredSchema.parse(result) + } + + /** Revoke origin access to a public directory; cached or downloaded bytes cannot be recalled. */ + async unpublishStoragePrefix( + prefix: string, + options?: { signal?: AbortSignal }, + ): Promise { + const result = await this._remoteJson({ + urlSuffix: '/storage/public_prefixes', + method: 'delete', + params: { prefix: normalizeStoragePublicPrefix(prefix) }, + signal: options?.signal, + }) + checkResult(result) + return storagePublicPrefixRevokedSchema.parse(result) + } + + /** List the workspace's explicitly public directories through the ordinary signed API. */ + async listPublicStoragePrefixes(options?: { + signal?: AbortSignal + }): Promise { + const result = await this._remoteJson({ + urlSuffix: '/storage/public_prefixes', + method: 'get', + signal: options?.signal, + }) + checkResult(result) + return storagePublicPrefixesSchema.parse(result) + } + /** * Get account Billing details for a specific month * @@ -1274,7 +1373,7 @@ export class Transloadit { }) } - private _calcSignature(toSign: string, algorithm = 'sha384'): string { + private _calcSignature(toSign: string, algorithm: string = this.#signatureAlgorithm): string { if (!this._authSecret) { throw new Error('Cannot sign params without authSecret.') } diff --git a/packages/node/src/alphalib/types/assemblyStatus.ts b/packages/node/src/alphalib/types/assemblyStatus.ts index de740f06..1cbb5fcb 100644 --- a/packages/node/src/alphalib/types/assemblyStatus.ts +++ b/packages/node/src/alphalib/types/assemblyStatus.ts @@ -337,6 +337,14 @@ export const assemblyStatusErrCodeSchema = z.enum([ 'TIGRIS_STORE_WRONG_REGION', 'TMP_FILE_DOWNLOAD_ERROR', 'TOKEN_INVALID_CREDENTIALS', + 'TRANSLOADIT_IMPORT_ACCESS_DENIED', + 'TRANSLOADIT_IMPORT_FAILURE', + 'TRANSLOADIT_IMPORT_NOT_FOUND', + 'TRANSLOADIT_IMPORT_VALIDATION', + 'TRANSLOADIT_STORE_CONFLICT', + 'TRANSLOADIT_STORE_FAILURE', + 'TRANSLOADIT_STORE_UNAVAILABLE', + 'TRANSLOADIT_STORE_VALIDATION', 'TUS_STORE_VALIDATION', 'USER_COMMAND_ERROR', 'VERIFIED_EMAIL_REQUIRED', @@ -660,6 +668,7 @@ export type AssemblyStatusUploads = z.infer export const assemblyStatusResultSchema = z .object({ id: z.string().optional(), + asset_id: z.string().optional(), basename: z.string().nullable().optional(), field: z.string().nullable().optional(), md5hash: z.string().nullable().optional(), diff --git a/packages/node/src/alphalib/types/robots/_index.ts b/packages/node/src/alphalib/types/robots/_index.ts index 809ae2a4..357fde53 100644 --- a/packages/node/src/alphalib/types/robots/_index.ts +++ b/packages/node/src/alphalib/types/robots/_index.ts @@ -391,6 +391,16 @@ import { interpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsSchema, meta as tlcdnDeliverMeta, } from './tlcdn-deliver.ts' +import { + interpolatableRobotTransloaditImportInstructionsSchema, + interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema, + meta as transloaditImportMeta, +} from './transloadit-import.ts' +import { + interpolatableRobotTransloaditStoreInstructionsSchema, + interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema, + meta as transloaditStoreMeta, +} from './transloadit-store.ts' import { interpolatableRobotTusStoreInstructionsSchema, interpolatableRobotTusStoreInstructionsWithHiddenFieldsSchema, @@ -554,6 +564,8 @@ const robotStepsInstructions: RobotSchemaOptions = [ interpolatableRobotTigrisImportInstructionsSchema, interpolatableRobotTigrisStoreInstructionsSchema, interpolatableRobotTlcdnDeliverInstructionsSchema, + interpolatableRobotTransloaditImportInstructionsSchema, + interpolatableRobotTransloaditStoreInstructionsSchema, interpolatableRobotTusStoreInstructionsSchema, interpolatableRobotUploadHandleInstructionsSchema, interpolatableRobotVideoAdaptiveInstructionsSchema, @@ -651,6 +663,8 @@ const robotStepsInstructionsWithHiddenFields: RobotSchemaOptions = [ interpolatableRobotTigrisImportInstructionsWithHiddenFieldsSchema, interpolatableRobotTigrisStoreInstructionsWithHiddenFieldsSchema, interpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsSchema, + interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema, + interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema, interpolatableRobotTusStoreInstructionsWithHiddenFieldsSchema, interpolatableRobotUploadHandleInstructionsWithHiddenFieldsSchema, interpolatableRobotVideoAdaptiveInstructionsWithHiddenFieldsSchema, @@ -808,6 +822,8 @@ export const robotsMeta = { tigrisImport, tigrisStore, tlcdnDeliverMeta, + transloaditImportMeta, + transloaditStoreMeta, tusStoreMeta, uploadHandleMeta, videoAdaptiveMeta, @@ -1302,6 +1318,18 @@ export type { InterpolatableRobotTlcdnDeliverInstructionsWithHiddenFields, InterpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsInput, } from './tlcdn-deliver.ts' +export type { + InterpolatableRobotTransloaditImportInstructions, + InterpolatableRobotTransloaditImportInstructionsInput, + InterpolatableRobotTransloaditImportInstructionsWithHiddenFields, + InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput, +} from './transloadit-import.ts' +export type { + InterpolatableRobotTransloaditStoreInstructions, + InterpolatableRobotTransloaditStoreInstructionsInput, + InterpolatableRobotTransloaditStoreInstructionsWithHiddenFields, + InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput, +} from './transloadit-store.ts' export type { InterpolatableRobotTusStoreInstructions, InterpolatableRobotTusStoreInstructionsInput, diff --git a/packages/node/src/alphalib/types/robots/_instructions-primitives.ts b/packages/node/src/alphalib/types/robots/_instructions-primitives.ts index 333757e3..1dee30b9 100644 --- a/packages/node/src/alphalib/types/robots/_instructions-primitives.ts +++ b/packages/node/src/alphalib/types/robots/_instructions-primitives.ts @@ -102,6 +102,8 @@ export const robotNames = z.enum([ 'TextSpeakRobot', 'TextTranslateRobot', 'FilePreviewRobot', + 'TransloaditImportRobot', + 'TransloaditStoreRobot', 'TusStoreRobot', 'ProgressSimulateRobot', ]) diff --git a/packages/node/src/alphalib/types/robots/transloadit-import.ts b/packages/node/src/alphalib/types/robots/transloadit-import.ts new file mode 100644 index 00000000..2a4d398a --- /dev/null +++ b/packages/node/src/alphalib/types/robots/transloadit-import.ts @@ -0,0 +1,88 @@ +import type { RobotMetaInput } from './_instructions-primitives.ts' + +import { z } from 'zod' + +import { interpolateRobot, recursive, robotBase, robotImport } from './_instructions-primitives.ts' + +export const meta: RobotMetaInput = { + bytescount: 10, + discount_factor: 0.1, + discount_pct: 90, + example_code: { + steps: { + imported: { + robot: '/transloadit/import', + path: 'photos/cat.jpg', + }, + }, + }, + example_code_description: 'Import a file from Transloadit Storage:', + has_small_icon: true, + isAllowedForUrlTransform: true, + isInternal: false, + minimum_charge: 0, + name: 'TransloaditImportRobot', + output_factor: 1, + override_lvl1: 'File Importing', + priceFactor: 10, + purpose_sentence: 'imports files from Transloadit Storage', + purpose_verb: 'import', + purpose_word: 'Transloadit Storage', + purpose_words: 'Import files from Transloadit Storage', + queueSlotCount: 10, + removeJobResultFilesFromDiskRightAfterStoringOnS3: true, + service_slug: 'file-importing', + slot_count: 10, + stage: 'beta', + title: 'Import files from Transloadit Storage', + typical_file_size_mb: 1.2, + typical_file_type: 'file', +} + +export const robotTransloaditImportInstructionsSchema = robotBase + .merge(robotImport) + .extend({ + robot: z.literal('/transloadit/import').describe(` +Imports a file from your workspace's Transloadit Storage by its path. +`), + path: z.string().describe(` +The path of the file in Transloadit Storage, for example \`photos/cat.jpg\`. +`), + recursive: recursive.describe(` +Whether to import files from subfolders and sub-subfolders when \`path\` is a folder. By default +only the folder's own files are imported. +`), + }) + .strict() + +export const robotTransloaditImportInstructionsWithHiddenFieldsSchema = + robotTransloaditImportInstructionsSchema.extend({ + result: z + .union([z.literal('debug'), robotTransloaditImportInstructionsSchema.shape.result]) + .optional(), + }) + +export type RobotTransloaditImportInstructions = z.infer< + typeof robotTransloaditImportInstructionsSchema +> +export type RobotTransloaditImportInstructionsWithHiddenFields = z.infer< + typeof robotTransloaditImportInstructionsWithHiddenFieldsSchema +> + +export const interpolatableRobotTransloaditImportInstructionsSchema = interpolateRobot( + robotTransloaditImportInstructionsSchema, +) +export type InterpolatableRobotTransloaditImportInstructions = + InterpolatableRobotTransloaditImportInstructionsInput + +export type InterpolatableRobotTransloaditImportInstructionsInput = z.input< + typeof interpolatableRobotTransloaditImportInstructionsSchema +> + +export const interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema = + interpolateRobot(robotTransloaditImportInstructionsWithHiddenFieldsSchema) +export type InterpolatableRobotTransloaditImportInstructionsWithHiddenFields = + InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput +export type InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput = z.input< + typeof interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema +> diff --git a/packages/node/src/alphalib/types/robots/transloadit-store.ts b/packages/node/src/alphalib/types/robots/transloadit-store.ts new file mode 100644 index 00000000..5b70f12c --- /dev/null +++ b/packages/node/src/alphalib/types/robots/transloadit-store.ts @@ -0,0 +1,96 @@ +import type { RobotMetaInput } from './_instructions-primitives.ts' + +import { z } from 'zod' + +import { interpolateRobot, robotBase, robotUse } from './_instructions-primitives.ts' + +export const meta: RobotMetaInput = { + bytescount: 10, + discount_factor: 0.1, + discount_pct: 90, + example_code: { + steps: { + stored: { + robot: '/transloadit/store', + use: ':original', + }, + }, + }, + example_code_description: 'Store uploaded files in Transloadit Storage:', + has_small_icon: true, + isAllowedForUrlTransform: false, + isInternal: false, + minimum_charge: 0, + name: 'TransloaditStoreRobot', + output_factor: 1, + override_lvl1: 'File Exporting', + priceFactor: 10, + purpose_sentence: 'stores files privately in Transloadit Storage', + purpose_verb: 'export', + purpose_word: 'Transloadit Storage', + purpose_words: 'Store files in Transloadit Storage', + queueSlotCount: 2, + removeJobResultFilesFromDiskRightAfterStoringOnS3: false, + service_slug: 'file-exporting', + slot_count: 2, + stage: 'beta', + title: 'Store files in Transloadit Storage', + trackOutputFileSize: true, + typical_file_size_mb: 1.2, + typical_file_type: 'file', +} + +export const robotTransloaditStoreInstructionsSchema = robotBase + .merge(robotUse) + .extend({ + robot: z.literal('/transloadit/store').describe(` +Stores each input privately in Transloadit Storage. +`), + conflict_strategy: z + .enum(['error', 'overwrite', 'rename']) + .default('rename') + .describe(` +Chooses how to handle an existing destination. +`), + path: z + .string() + .default('${file.url_name}') + .describe(` +Sets the destination path inside your Transloadit Storage workspace, relative to its root: a +filename, or folders and a filename such as \`website/hero.jpg\`. Folders that do not exist yet +are created. +`), + }) + .strict() + +export const robotTransloaditStoreInstructionsWithHiddenFieldsSchema = + robotTransloaditStoreInstructionsSchema.extend({ + result: z + .union([z.literal('debug'), robotTransloaditStoreInstructionsSchema.shape.result]) + .optional(), + }) + +export type RobotTransloaditStoreInstructions = z.infer< + typeof robotTransloaditStoreInstructionsSchema +> +export type RobotTransloaditStoreInstructionsWithHiddenFields = z.infer< + typeof robotTransloaditStoreInstructionsWithHiddenFieldsSchema +> + +export const interpolatableRobotTransloaditStoreInstructionsSchema = interpolateRobot( + robotTransloaditStoreInstructionsSchema, +) +export type InterpolatableRobotTransloaditStoreInstructions = + InterpolatableRobotTransloaditStoreInstructionsInput + +export type InterpolatableRobotTransloaditStoreInstructionsInput = z.input< + typeof interpolatableRobotTransloaditStoreInstructionsSchema +> + +export const interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema = + interpolateRobot(robotTransloaditStoreInstructionsWithHiddenFieldsSchema) +export type InterpolatableRobotTransloaditStoreInstructionsWithHiddenFields = + InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput +export type InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput = z.input< + typeof interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema +> diff --git a/packages/node/src/cli/commands/BaseCommand.ts b/packages/node/src/cli/commands/BaseCommand.ts index 17688cd8..28ea82cf 100644 --- a/packages/node/src/cli/commands/BaseCommand.ts +++ b/packages/node/src/cli/commands/BaseCommand.ts @@ -1,3 +1,4 @@ +import type { ResolvedCliConfig } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' import { Command, Option } from 'clipanion' @@ -22,6 +23,7 @@ abstract class BaseCommand extends Command { protected output!: IOutputCtl protected client!: TransloaditClient + protected cliConfig!: ResolvedCliConfig protected setupOutput(): void { const logLevel = this.logLevelOption ? parseLogLevel(this.logLevelOption) : LOG_LEVEL_DEFAULT @@ -31,8 +33,8 @@ abstract class BaseCommand extends Command { }) } - protected setupClient(): boolean { - const config = resolveCliConfig() + protected setupClient(config: ResolvedCliConfig = resolveCliConfig()): boolean { + this.cliConfig = config if (config.auth == null) { this.output.error(config.loadError ?? buildMissingAuthMessage()) return false diff --git a/packages/node/src/cli/commands/auth.ts b/packages/node/src/cli/commands/auth.ts index 31da34d8..74d31b18 100644 --- a/packages/node/src/cli/commands/auth.ts +++ b/packages/node/src/cli/commands/auth.ts @@ -110,10 +110,10 @@ function parseJsonObject( // Core logic for signature generation function generateSignature( input: string, - credentials: { authKey: string; authSecret: string }, + credentials: CliKeySecretCredentials, algorithm?: string, ): OutputResult { - const { authKey, authSecret } = credentials + const { authKey } = credentials let params: CliSignatureParams if (input === '') { @@ -136,7 +136,7 @@ function generateSignature( } } - const client = new Transloadit({ authKey, authSecret }) + const client = new Transloadit(credentials) try { const signature = client.calcSignature(params as OptionalAuthParams, algorithm) return { ok: true, output: JSON.stringify(signature) } @@ -286,6 +286,29 @@ export async function runSmartSig(options: RunSmartSigOptions = {}): Promise { + const commands = this.cli + .definitions() + .filter((command) => command.path.startsWith(`${this.cli.binaryName} auth `)) + this.context.stdout.write( + [ + 'Authentication commands', + '', + ...commands.map((command) => ` ${command.path}\n ${command.description ?? ''}`), + '', + 'Use --help for options.', + '', + ].join('\n'), + ) + return Promise.resolve() + } +} + /** * Generate a signature for assembly params */ diff --git a/packages/node/src/cli/commands/image.ts b/packages/node/src/cli/commands/image.ts new file mode 100644 index 00000000..6082bebd --- /dev/null +++ b/packages/node/src/cli/commands/image.ts @@ -0,0 +1,264 @@ +import { lstat, mkdir, open, rm } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve } from 'node:path' + +import { validateStoragePathPrefix } from '@transloadit/utils' +import { Command, Option } from 'clipanion' +import { z } from 'zod' + +import { noticeCliCredentialSource, resolveCliConfig } from '../helpers.ts' +import { storagePublicError } from '../storagePublic.ts' +import { + defaultStorageCatalog, + readStorageCatalog, + storageCatalogDelivery, + storageTypesPath, + updateStorageReceipts, +} from '../storageReceipts.ts' +import { resolveStorageWorkspace } from '../storageS3.ts' +import { nextAppRoot, storageImageEnvBlock, storageImagePage } from '../storageSnippets.ts' +import { ensureError, isErrnoException } from '../types.ts' +import { UnauthenticatedCommand } from './BaseCommand.ts' + +/** Scaffolds the Node-runtime Next.js integration without overwriting code or env files. */ +export class ImageInitCommand extends UnauthenticatedCommand { + static override paths = [['image', 'init']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'Optionally scaffold a Storage image example or private authorizer', + examples: [['Scaffold an example', 'transloadit image init website/ --example']], + }) + + privateDelivery = Option.Boolean('--private', false, { + description: 'Also generate a redirect route; denies access until you supply authorization', + }) + example = Option.Boolean('--example', false, { + description: 'Generate an example page; explicit factories remain an optional escape hatch', + }) + publicDelivery = Option.Boolean('--public', false, { + description: 'Publish this directory on the server and use permanent unsigned image URLs', + }) + receipts = Option.String('--receipts', defaultStorageCatalog, { + description: 'Rendering catalog selected by withTransloaditImages', + }) + writeEnv = Option.Boolean('--write-env', false, { + description: 'Reuse the saved login in an owner-only .env.local; never overwrite it', + }) + prefix = Option.String({ required: true }) + workspace = Option.String('--workspace', { + description: 'Workspace expected for the selected credentials', + }) + + protected async run(): Promise { + const created: string[] = [] + let published: string | undefined + try { + if (this.privateDelivery && this.publicDelivery) + throw new Error('Choose either --private or --public, not both') + if (!this.privateDelivery && !this.publicDelivery && !this.example) + throw new Error('Choose --example or --private; normal uploads need only storage store') + const prefix = + this.prefix.endsWith('/') || this.prefix === '' ? this.prefix : `${this.prefix}/` + try { + validateStoragePathPrefix(prefix, 0, 'prefix') + if (prefix === '') throw new Error('Image scaffolds must name a directory') + } catch (error) { + throw new Error( + 'Provide one safe relative directory prefix ending in /, for example website/', + { cause: error }, + ) + } + const root = nextAppRoot() + if (root === undefined) + throw new Error('Run image init in a Next.js project containing app/ or src/app/') + const catalogArgument = relative(process.cwd(), resolve(this.receipts)).replaceAll('\\', '/') + if ( + catalogArgument === '..' || + catalogArgument.startsWith('../') || + isAbsolute(catalogArgument) + ) + throw new Error( + `Catalog ${JSON.stringify(this.receipts)} is outside this Next.js app. Move it inside the app for package-import scaffolding, or use an explicit createStorageImages factory for a shared external catalog. Nothing was written or published.`, + ) + const catalog = await readStorageCatalog(this.receipts) + const needsCredentials = this.publicDelivery || catalog === undefined || this.writeEnv + let environment: string | undefined + const saved = resolveCliConfig('login') + if (needsCredentials && saved.loadError !== undefined) throw new Error(saved.loadError) + const login = saved.auth === undefined ? resolveCliConfig() : saved + if (needsCredentials && !this.setupClient(login)) return 1 + if (needsCredentials) noticeCliCredentialSource(login, this.output) + const selectedEndpoint = this.endpoint ?? (catalog === undefined ? login.endpoint : undefined) + const deliveryEndpoint = + selectedEndpoint === undefined || + new URL(selectedEndpoint).origin === 'https://api2.transloadit.com' + ? undefined + : selectedEndpoint + if (this.writeEnv && this.privateDelivery) { + const value = z + .string() + .min(1) + .max(4096) + .regex(/^[^\s][^\r\n\0]*$/) + .refine((text) => text.trim() === text) + const renderingValues = z.object({ TRANSLOADIT_KEY: value, TRANSLOADIT_SECRET: value }) + const parsed = renderingValues.safeParse({ + TRANSLOADIT_KEY: saved.credentials?.authKey, + TRANSLOADIT_SECRET: saved.credentials?.authSecret, + }) + if (!parsed.success) + throw new Error( + 'Run transloadit auth login first to save your workspace and Auth Key. Nothing was written.', + ) + // Next expands $ even in quoted dotenv values. Reject delimiters instead of silently + // changing an opaque secret; JSON quoting alone is not dotenv/Next escaping. + environment = Object.entries(parsed.data) + .map(([name, text]) => { + if (/["\\]/.test(text)) + throw new Error( + 'Rendering values cannot contain double quotes or backslashes; configure these values through the application environment instead', + ) + return `${name}="${text.replaceAll('$', '\\$')}"\n` + }) + .join('') + } + const pageDirectory = `${root}app/storage-image-example` + const example = this.example || this.publicDelivery + const files = [ + ...(example + ? [ + { + path: `${pageDirectory}/page.tsx`, + content: storageImagePage( + relative(resolve(pageDirectory), resolve(this.receipts)).replaceAll('\\', '/'), + prefix, + catalogArgument === defaultStorageCatalog ? undefined : catalogArgument, + ), + }, + ] + : []), + ...(this.privateDelivery + ? [ + { + path: 'transloadit.authorize.ts', + content: [ + "import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server'", + '', + '// Replace with your application session and per-object authorization.', + 'export const authorize: AuthorizeTransloaditStorageImage = () => false', + '', + ].join('\n'), + }, + { + path: `${root}app/api/storage-images/route.ts`, + content: "export { GET, HEAD } from '@transloadit/img/next/route'\n", + }, + ] + : []), + ...(environment === undefined ? [] : [{ path: '.env.local', content: environment }]), + ] + // Discover conflicts before changing server policy; exclusive creates still protect races. + for (const file of files) { + const existing = await lstat(file.path).catch((error: unknown) => { + if (isErrnoException(error) && error.code === 'ENOENT') return undefined + throw error + }) + if (existing !== undefined) + throw new Error( + `Refusing to overwrite ${JSON.stringify(file.path)}. Move or rename it before rerunning image init; existing files were left unchanged.`, + ) + } + await updateStorageReceipts(this.receipts, async (previous, signal) => { + // A verified workspace name is not an environment binding. Publishing or saving keys + // must not silently cross from the login's API to a different catalog's delivery origin. + if ( + needsCredentials && + this.endpoint === undefined && + previous !== undefined && + new URL(previous.delivery?.baseUrl ?? 'https://api2.transloadit.com').origin !== + new URL(login.endpoint ?? 'https://api2.transloadit.com').origin + ) + throw new Error( + 'The login endpoint does not match this catalog’s delivery. Select matching credentials, a separate --receipts catalog, or --endpoint to deliberately change delivery. Nothing was written.', + ) + const workspace = + !needsCredentials && previous !== undefined + ? previous.workspace + : await resolveStorageWorkspace(this, login, previous?.workspace, signal) + if (this.workspace !== undefined && this.workspace !== workspace) + throw new Error( + `Project uses ${workspace}, not ${JSON.stringify(this.workspace)}. Nothing was written.`, + ) + if (previous !== undefined && previous.workspace !== workspace) + throw new Error( + 'Use --receipts with a separate catalog when initializing another workspace. Nothing was written.', + ) + if (this.publicDelivery) { + this.output.notice( + `Publishing ${prefix} recursively: all current and future objects under this prefix will be public.`, + ) + const result = await this.client + .publishStoragePrefix(prefix, { signal }) + .catch((cause: unknown) => { + signal.throwIfAborted() + throw new Error(storagePublicError(cause, workspace), { cause }) + }) + published = result.prefix + } + return { + ...previous, + workspace, + // An absent delivery block means production, not permission to import another login. + delivery: + this.endpoint === undefined && previous !== undefined + ? previous.delivery + : storageCatalogDelivery(selectedEndpoint), + public: [ + ...new Set([ + ...(previous?.public ?? []), + ...(published === undefined ? [] : [published]), + ]), + ], + images: previous?.images ?? {}, + } + }) + if (catalog === undefined) created.push(this.receipts, storageTypesPath(this.receipts)) + for (const file of files) { + await mkdir(dirname(file.path), { recursive: true }) + const handle = await open(file.path, 'wx', file.path === '.env.local' ? 0o600 : 0o666) + created.push(file.path) + try { + await handle.writeFile(file.content) + } finally { + await handle.close() + } + } + const instruction = this.privateDelivery + ? 'Connect your application session and per-object authorization in transloadit.authorize.ts; the generated handler denies access until then. Use a separate application key with Smart CDN on and smart_cdn:sign; assemblies:write is also accepted, but grants broader Assembly access. Configure it in Console → Credentials.' + : this.publicDelivery + ? 'The directory is published. Public images use permanent unsigned CDN URLs.' + : 'Example created using the existing catalog; no publication policy was changed.' + if (deliveryEndpoint !== undefined) + this.output.print( + `Delivery uses the non-production API ${new URL(deliveryEndpoint).origin}; remove the catalog delivery block for Smart CDN delivery.`, + { deliveryEndpoint: new URL(deliveryEndpoint).origin }, + ) + const envBlock = storageImageEnvBlock(this.publicDelivery) + const plugin = `Enable withTransloaditImages in next.config.ts${catalogArgument === defaultStorageCatalog ? '' : ` with { catalog: ${JSON.stringify(catalogArgument)} }`}.` + this.output.print( + `Created ${created.join(', ')}\n${instruction}\n${plugin}\n${example ? `Add an image under ${prefix} with storage store and open /storage-image-example. ` : ''}Commit ${this.receipts} and transloadit-images.d.ts.\n${!this.privateDelivery ? 'Public rendering needs no environment variables, locally or on your host.' : this.writeEnv ? 'Rendering values were saved privately; never commit .env.local.' : `Add your rendering values to .env.local:\n${envBlock}`}`, + { files: created, environment: envBlock }, + ) + return undefined + } catch (error) { + for (const path of created) { + await rm(path).catch(() => { + this.output.error(`Could not remove partial scaffold file ${path}; remove it manually.`) + }) + } + this.output.error( + `${ensureError(error).message}${published === undefined ? '' : ` The server prefix ${published} remains public; use storage unpublish deliberately if needed.`}`, + ) + return 1 + } + } +} diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index f4e5602c..08888248 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -12,10 +12,25 @@ import { AssemblyInstructionsCompileCommand, RunCommand, } from './assemblies.ts' -import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth.ts' +import { + AuthHelpCommand, + SignatureCommand, + SmartCdnSignatureCommand, + TokenCommand, +} from './auth.ts' import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' +import { ImageInitCommand } from './image.ts' +import { AuthLoginCommand, AuthLogoutCommand, AuthStatusCommand } from './login.ts' import { NotificationsReplayCommand } from './notifications.ts' +import { + StorageListCommand, + StoragePublicationsCommand, + StoragePublishCommand, + StorageReceiptsSyncCommand, + StorageStoreCommand, + StorageUnpublishCommand, +} from './storage.ts' import { TemplatesCreateCommand, TemplatesDeleteCommand, @@ -38,9 +53,13 @@ export function createCli(): Cli { cli.register(Builtins.VersionCommand) // Auth commands (signature generation) + cli.register(AuthHelpCommand) cli.register(SignatureCommand) cli.register(SmartCdnSignatureCommand) cli.register(TokenCommand) + cli.register(AuthLoginCommand) + cli.register(AuthLogoutCommand) + cli.register(AuthStatusCommand) // Assemblies commands cli.register(AssembliesCreateCommand) @@ -66,6 +85,13 @@ export function createCli(): Cli { // Uploads commands cli.register(UploadCommand) + cli.register(StorageStoreCommand) + cli.register(StorageListCommand) + cli.register(StoragePublicationsCommand) + cli.register(StoragePublishCommand) + cli.register(StorageUnpublishCommand) + cli.register(StorageReceiptsSyncCommand) + cli.register(ImageInitCommand) // Prompt-to-Assembly-Instructions commands cli.register(AssemblyInstructionsCompileCommand) diff --git a/packages/node/src/cli/commands/login.ts b/packages/node/src/cli/commands/login.ts new file mode 100644 index 00000000..69992478 --- /dev/null +++ b/packages/node/src/cli/commands/login.ts @@ -0,0 +1,333 @@ +import type { CliKeySecretCredentials } from '../helpers.ts' + +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname } from 'node:path' + +import { Command, Option } from 'clipanion' +import { parse } from 'dotenv' +import { z } from 'zod' + +import { Transloadit } from '../../Transloadit.ts' +import { deviceLogin } from '../deviceLogin.ts' +import { + cliSignatureAlgorithmSchema, + getConfiguredCredentialsFilePath, + readCliInput, + resolveCliConfig, +} from '../helpers.ts' +import { quoteCredential } from '../secretInput.ts' +import { ensureError, isErrnoException } from '../types.ts' +import { UnauthenticatedCommand } from './BaseCommand.ts' + +const credentialSchema = z + .string() + .trim() + .min(1) + .max(4096) + .regex(/^[^\r\n\0]+$/) +const credentialsSchema = z.object({ + TRANSLOADIT_SIGNATURE_ALGORITHM: cliSignatureAlgorithmSchema.optional(), + TRANSLOADIT_KEY: credentialSchema, + TRANSLOADIT_SECRET: credentialSchema, + TRANSLOADIT_WORKSPACE: z + .string() + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/) + .optional(), +}) + +async function existingLoginMessage(file: string): Promise { + const metadata = await lstat(file) + .then(async (info) => { + if (!info.isFile()) return 'Saved login metadata unavailable (not a regular file).' + const fields = parse(await readFile(file, 'utf8')) + // Only identity fields are displayed; JSON quoting keeps saved terminal controls inert. + return [ + `Workspace: ${fields.TRANSLOADIT_WORKSPACE ? JSON.stringify(fields.TRANSLOADIT_WORKSPACE) : 'not recorded'}`, + `Description: ${fields.TRANSLOADIT_AUTH_KEY_DESCRIPTION ? JSON.stringify(fields.TRANSLOADIT_AUTH_KEY_DESCRIPTION) : 'not recorded'}`, + `File modified: ${info.mtime.toISOString()}`, + ].join('\n') + }) + .catch(() => 'Saved login metadata unavailable (could not read the file).') + return [ + `Credentials already exist at ${JSON.stringify(file)}. The saved file was preserved.`, + metadata, + 'For a separate login, set TRANSLOADIT_CREDENTIALS_FILE to another file path and run transloadit auth login again.', + 'Use --replace only if you intend to overwrite this saved login.', + ].join('\n') +} + +/** Saves CLI-only credentials without passing secrets through command-line arguments. */ +export class AuthLoginCommand extends UnauthenticatedCommand { + static override paths = [['auth', 'login']] + static override usage = Command.Usage({ + category: 'Authentication', + description: 'Log in through the browser and save your workspace Auth Key privately', + details: + 'Opens a one-time browser approval; --no-browser prints the URL without opening it. --stdin accepts existing credentials and verifies a signed Template read. Honors a shell TRANSLOADIT_CREDENTIALS_FILE override, never one from project .env, and refuses app env files. Existing credentials require --replace. Uses production unless --endpoint is explicitly supplied.', + }) + + stdin = Option.Boolean('--stdin', false, { + description: + 'Read TRANSLOADIT_KEY, TRANSLOADIT_SECRET and optional TRANSLOADIT_WORKSPACE in dotenv format; combined Smart CDN keys also need TRANSLOADIT_SIGNATURE_ALGORITHM=sha256', + }) + noBrowser = Option.Boolean('--no-browser', false, { + description: 'Print the device approval URL without launching a browser', + }) + replace = Option.Boolean('--replace', false, { + description: 'Explicitly replace an existing regular credentials file', + }) + + protected async run(): Promise { + const file = getConfiguredCredentialsFilePath('shell') + const temporary = `${file}.${randomUUID()}.tmp` + let ownsTemporary = false + let loginWorkspace: string | undefined + try { + if (/^\.env(?:\.|$)/i.test(basename(file))) + throw new Error('Credentials destination must not be an app env file') + const info = await lstat(file).catch((error: unknown) => { + if (isErrnoException(error) && error.code === 'ENOENT') return undefined + throw error + }) + if (info !== undefined) { + if (!info.isFile()) + throw new Error( + 'Credentials must be a regular file; symlinks and directories are not replaced', + ) + if (!this.replace) throw new Error(await existingLoginMessage(file)) + } + // Never send newly pasted credentials to a project-controlled dotenv endpoint. + const endpoint = new URL(this.endpoint ?? 'https://api2.transloadit.com') + if ( + (endpoint.protocol !== 'https:' && + !( + endpoint.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname) + )) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + endpoint.pathname !== '/' + ) + throw new Error( + 'Login endpoint must be an HTTPS API origin (HTTP is allowed only on loopback)', + ) + const origin = endpoint.origin + let credentials: CliKeySecretCredentials & { + workspace?: string + authKeyId?: string + description?: string + } + if (this.stdin) { + const input = credentialsSchema.safeParse( + parse((await readCliInput({ inputPath: '-' })).content ?? ''), + ) + if (!input.success) + throw new Error( + 'Provide a valid TRANSLOADIT_KEY and TRANSLOADIT_SECRET pair; nothing was saved', + ) + credentials = { + authKey: input.data.TRANSLOADIT_KEY, + authSecret: input.data.TRANSLOADIT_SECRET, + workspace: input.data.TRANSLOADIT_WORKSPACE, + ...(input.data.TRANSLOADIT_SIGNATURE_ALGORITHM === undefined + ? {} + : { signatureAlgorithm: input.data.TRANSLOADIT_SIGNATURE_ALGORITHM }), + } + const client = new Transloadit({ + ...credentials, + endpoint: origin, + maxRetries: 0, + timeout: 10_000, + }) + await client.listTemplates({ pagesize: 1 }).catch((error: unknown) => { + throw new Error( + `Could not verify these credentials. Check the endpoint, key/secret and read scope at https://transloadit.com/c/${credentials.workspace ?? ''}/template-credentials/. Combined Smart CDN keys need TRANSLOADIT_SIGNATURE_ALGORITHM=sha256 in the stdin input. Nothing was saved.`, + { cause: error }, + ) + }) + } else credentials = await deviceLogin(origin, this.output, this.noBrowser) + loginWorkspace = credentials.workspace + const fields = { + TRANSLOADIT_KEY: credentials.authKey, + TRANSLOADIT_SECRET: credentials.authSecret, + TRANSLOADIT_WORKSPACE: credentials.workspace, + TRANSLOADIT_WORKSPACE_VERIFIED: this.stdin ? undefined : 'true', + TRANSLOADIT_LOGIN_METHOD: this.stdin ? 'stdin' : 'device', + TRANSLOADIT_SIGNATURE_ALGORITHM: credentials.signatureAlgorithm, + TRANSLOADIT_ENDPOINT: this.endpoint === undefined ? undefined : origin, + TRANSLOADIT_AUTH_KEY_ID: credentials.authKeyId, + TRANSLOADIT_AUTH_KEY_DESCRIPTION: credentials.description, + } + const data = `${Object.entries(fields) + .flatMap(([name, value]) => + value === undefined ? [] : [`${name}=${quoteCredential(value)}`], + ) + .join('\n')}\n` + await mkdir(dirname(file), { recursive: true, mode: 0o700 }) + if (this.replace) { + const info = await lstat(file).catch((error: unknown) => { + if (isErrnoException(error) && error.code === 'ENOENT') return undefined + throw error + }) + if (info !== undefined && !info.isFile()) + throw new Error( + 'Credentials must be a regular file; symlinks and directories are not replaced', + ) + await writeFile(temporary, data, { flag: 'wx', mode: 0o600 }) + ownsTemporary = true + await rename(temporary, file) + } else { + await writeFile(file, data, { flag: 'wx', mode: 0o600 }) + } + // A signed read checks dam:write access and catalog readiness without publishing anything. + // It does not prove that the worker's object store can accept a later upload. + const storagePolicyAccess = await new Transloadit({ + ...credentials, + endpoint: origin, + maxRetries: 0, + timeout: 10_000, + }) + .listPublicStoragePrefixes() + .then( + () => true, + () => false, + ) + if (!storagePolicyAccess) { + const workspace = + credentials.workspace !== undefined && + /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(credentials.workspace) + ? credentials.workspace + : '' + this.output.warn( + `Login saved, but Storage policy access could not be verified. Before uploading, check Storage availability and the Auth Key dam:write scope at https://transloadit.com/c/${workspace}/template-credentials/. A temporary network failure can also prevent this check; retry with transloadit storage publications.`, + ) + } + const message = + credentials.workspace === undefined + ? `Verified one signed Template read and saved CLI credentials to ${file}. Application env files were not changed.` + : `Logged in to workspace ${credentials.workspace}` + this.output.print( + `${message}${storagePolicyAccess ? '\nStorage policy access verified.' : ''}`, + { + saved: file, + storagePolicyAccess, + verified: true, + ...(credentials.workspace === undefined ? {} : { workspace: credentials.workspace }), + }, + ) + return undefined + } catch (error) { + this.output.error( + isErrnoException(error) && error.code === 'EEXIST' + ? [ + await existingLoginMessage(file), + `This login’s ${this.stdin ? 'verified' : 'approved'} Auth Key was not saved.`, + this.stdin + ? 'No new Auth Key was created. The supplied key may still be used by other applications.' + : `Review it in Console → Credentials: https://transloadit.com/c/${loginWorkspace ?? ''}/template-credentials/; revoke it there if no longer needed.`, + 'Be careful: auth logout would use the saved login, not this unsaved one.', + ].join('\n') + : ensureError(error).message, + ) + return 1 + } finally { + if (ownsTemporary) await rm(temporary, { force: true }) + } + } +} + +/** Shows the saved CLI login, never a signing key or secret. */ +export class AuthStatusCommand extends UnauthenticatedCommand { + static override paths = [['auth', 'status']] + static override usage = Command.Usage({ + category: 'Authentication', + description: 'Show the saved CLI workspace and key description', + }) + protected run(): Promise { + const config = resolveCliConfig('login') + if (config.credentials === undefined) { + this.output.error(config.loadError ?? 'Not logged in. Run transloadit auth login.') + return Promise.resolve(1) + } + const workspace = config.credentialsWorkspace ?? 'not recorded' + const description = + config.credentialsDescription ?? 'Existing Auth Key (description not recorded)' + this.output.print( + `Saved login: ${workspace}\n${description}\nStorage commands report any shell or project credential override before use.`, + { workspace, description }, + ) + return Promise.resolve(undefined) + } +} + +/** Revokes browser-login keys; imported application keys need explicit revocation consent. */ +export class AuthLogoutCommand extends UnauthenticatedCommand { + static override paths = [['auth', 'logout']] + static override usage = Command.Usage({ + category: 'Authentication', + description: 'Remove saved credentials; browser-login keys are also revoked', + }) + revoke = Option.Boolean('--revoke', { + description: 'Also revoke an imported Auth Key; other applications using it will stop working', + }) + protected async run(): Promise { + try { + if (this.revoke === false) + throw new Error( + '--no-revoke is not supported. Nothing was changed; browser-login logout revokes its key.', + ) + const file = getConfiguredCredentialsFilePath('shell') + if (/^\.env(?:\.|$)/i.test(basename(file))) + throw new Error('Logout never removes application env files') + const info = await lstat(file) + if (!info.isFile()) + throw new Error('Logout requires a regular credentials file, not a symlink or directory') + const before = await readFile(file, 'utf8') + // Imported and legacy keys may be shared with applications; never infer disposability. + const revoke = this.revoke === true || parse(before).TRANSLOADIT_LOGIN_METHOD === 'device' + if (revoke) { + const config = resolveCliConfig('login') + if (config.credentials === undefined) throw new Error(config.loadError ?? 'Not logged in') + const endpoint = config.credentialsEndpoint ?? 'https://api2.transloadit.com' + if ( + this.endpoint !== undefined && + new URL(this.endpoint).origin !== new URL(endpoint).origin + ) + throw new Error('Logout must use the saved login endpoint; no credentials were sent') + await new Transloadit({ ...config.credentials, endpoint, maxRetries: 0, timeout: 10_000 }) + .revokeOwnAuthKey() + .catch((cause: unknown) => { + throw new Error( + 'The CLI key was not revoked. Check connectivity and Console key permissions, then retry; the credentials file was kept.', + { cause }, + ) + }) + } + if (!(await lstat(file)).isFile() || (await readFile(file, 'utf8')) !== before) + throw new Error( + `${revoke ? 'The key was revoked, but the' : 'The'} credentials file changed during logout and was preserved`, + ) + await rm(file) + this.output.print( + revoke + ? 'CLI key revoked and saved credentials removed.' + : 'Saved credentials removed. Remote credentials were not revoked.', + { + revoked: revoke, + removed: true, + }, + ) + return undefined + } catch (error) { + this.output.error( + isErrnoException(error) && error.code === 'ENOENT' + ? 'Not logged in; no saved credentials file.' + : ensureError(error).message, + ) + return 1 + } + } +} diff --git a/packages/node/src/cli/commands/storage.ts b/packages/node/src/cli/commands/storage.ts new file mode 100644 index 00000000..f1ec5fd0 --- /dev/null +++ b/packages/node/src/cli/commands/storage.ts @@ -0,0 +1,775 @@ +import type { HeadObjectCommandOutput } from '@aws-sdk/client-s3' + +import type { StoredImageReceipt } from '../../storageImage.ts' + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { basename, posix, resolve } from 'node:path' + +import { validateStoragePath } from '@transloadit/utils' +import { Command, Option } from 'clipanion' +import pMap from 'p-map' +import { z } from 'zod' + +import { ApiError } from '../../ApiError.ts' +import InconsistentResponseError from '../../InconsistentResponseError.ts' +import { normalizeStoragePublicPrefix } from '../../storagePublicPrefixes.ts' +import { Transloadit } from '../../Transloadit.ts' +import { noticeCliCredentialSource, quoteCliArgument, resolveCliConfig } from '../helpers.ts' +import { storagePublicError } from '../storagePublic.ts' +import { + assertStorageWorkspace, + defaultStorageCatalog, + readStorageCatalog, + storageCatalogDelivery, + storageTypesPath, + updateStorageReceipts, +} from '../storageReceipts.ts' +import { + listStorageObjects, + resolveStorageWorkspace, + storageS3ConnectionErrorSchema, + storageS3ErrorSchema, + storageS3ReadAdvice, + withStorageS3, +} from '../storageS3.ts' +import { storageImageConfigAdvice, storageImagePrivateAdvice } from '../storageSnippets.ts' +import { ensureError } from '../types.ts' +import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts' + +abstract class StorageProjectCommand extends AuthenticatedCommand { + receipts = Option.String('--receipts', defaultStorageCatalog, { + description: 'Committed project catalog with workspace, public prefixes and image receipts', + }) + workspace = Option.String('--workspace', { + description: + 'Explicit workspace override; a different workspace never changes this project catalog', + }) +} + +/** Publishes an explicit directory, independently of uploads or local snippet generation. */ +export class StoragePublishCommand extends StorageProjectCommand { + static override paths = [['storage', 'publish']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'Declare a directory public for unsigned Smart CDN delivery', + }) + prefix = Option.String({ required: true }) + dryRun = Option.Boolean('--dry-run', false, { + description: 'List matching objects without publishing or changing the catalog', + }) + protected async run(): Promise { + try { + noticeCliCredentialSource(this.cliConfig, this.output) + const prefix = normalizeStoragePublicPrefix(this.prefix) + if (this.dryRun) { + const catalog = await readStorageCatalog(this.receipts) + const objects = await withStorageS3( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: catalog?.workspace, + }, + (client, workspace) => listStorageObjects(client, workspace, prefix), + 'Publication dry run', + undefined, + this.cliConfig, + ) + this.output.print( + `Would publish ${prefix} recursively, including future objects. Nothing changed.\n${objects.length === 0 ? 'No stored objects currently match.' : objects.map(({ path, size }) => `${JSON.stringify(path)}\t${size} bytes`).join('\n')}`, + { prefix, objects }, + ) + return undefined + } + await updateStorageReceipts(this.receipts, async (previous, signal) => { + const workspace = await resolveStorageWorkspace( + this, + this.cliConfig, + previous?.workspace, + signal, + ) + const result = await this.client + .publishStoragePrefix(prefix, { signal }) + .catch((cause: unknown) => { + signal.throwIfAborted() + throw new Error(storagePublicError(cause, workspace), { cause }) + }) + this.output.print( + `Published ${result.prefix}. Files under this directory can be served without signatures.`, + result, + ) + if (previous !== undefined && previous.workspace !== workspace) { + this.output.notice( + `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}.`, + ) + return undefined + } + return { + ...previous, + workspace, + delivery: + previous?.delivery ?? storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint), + public: [...new Set([...(previous?.public ?? []), result.prefix])], + images: previous?.images ?? {}, + } + }) + return undefined + } catch (error) { + this.output.error(ensureError(error).message) + return 1 + } + } +} + +/** Revokes a public prefix at the origin without promising to recall cached bytes. */ +export class StorageUnpublishCommand extends StorageProjectCommand { + static override paths = [['storage', 'unpublish']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'Revoke public origin access to a directory', + }) + prefix = Option.String({ required: true }) + protected async run(): Promise { + try { + noticeCliCredentialSource(this.cliConfig, this.output) + const prefix = normalizeStoragePublicPrefix(this.prefix) + await updateStorageReceipts(this.receipts, async (previous, signal) => { + const workspace = await resolveStorageWorkspace( + this, + this.cliConfig, + previous?.workspace, + signal, + ) + const result = await this.client + .unpublishStoragePrefix(prefix, { signal }) + .catch((cause: unknown) => { + signal.throwIfAborted() + throw new Error(storagePublicError(cause, workspace), { cause }) + }) + this.output.print( + `Unpublished ${result.prefix}; already cached or downloaded bytes cannot be recalled.`, + result, + ) + if (previous !== undefined && previous.workspace !== workspace) { + this.output.notice( + `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}.`, + ) + return undefined + } + return { + ...previous, + workspace, + public: (previous?.public ?? []).filter((prefix) => prefix !== result.prefix), + images: previous?.images ?? {}, + } + }) + return undefined + } catch (error) { + this.output.error(ensureError(error).message) + return 1 + } + } +} + +/** Lists explicit publication policy using the signed API, not S3 discovery. */ +export class StoragePublicationsCommand extends AuthenticatedCommand { + static override paths = [['storage', 'publications']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'List explicitly public Storage directories', + }) + protected async run(): Promise { + try { + noticeCliCredentialSource(this.cliConfig, this.output) + const result = await this.client.listPublicStoragePrefixes() + this.output.print( + result.public_prefixes.length === 0 + ? 'No Storage directories are public.' + : result.public_prefixes.map(({ prefix }) => prefix).join('\n'), + result, + ) + return undefined + } catch (error) { + this.output.error(storagePublicError(error, this.cliConfig.authWorkspace)) + return 1 + } + } +} + +interface CliStoredImageReceipt extends StoredImageReceipt { + source?: string + apiOrigin?: string +} + +async function hashImageFile( + file: string, + signal: AbortSignal, +): Promise<{ md5hash: string; size: number }> { + const hash = createHash('md5') + let size = 0 + // A bounded preflight lets the CLI skip an upload. storeImage still independently verifies it. + for await (const chunk of createReadStream(file, { signal })) { + hash.update(chunk) + size += chunk.length + } + if (size === 0) throw new Error('Cannot store an empty image') + signal.throwIfAborted() + return { md5hash: hash.digest('hex'), size } +} + +/** Stores originals in order, checkpointing each verified receipt before the next upload. */ +export class StorageStoreCommand extends StorageProjectCommand { + static override paths = [['storage', 'store']] + + static override usage = Command.Usage({ + category: 'Storage', + description: 'Store original images and save verified metadata for StorageImage', + details: ` + Uses the CLI's Assembly credentials (environment, .env or ~/.transloadit/credentials). + Storage writes must be enabled. Existing Storage paths conflict unless --overwrite is explicit. + --hashed inserts eight MD5 hex digits before the extension. Matching catalog receipts skip + repeat uploads; changed bytes get a fresh name. --hashed cannot be combined with --overwrite. + The project catalog binds workspace, published prefixes and image receipts. Each successful + upload is saved atomically before the next. Do not run two writers against the same catalog. + The catalog defaults to transloadit.images.json; --receipts selects another file. + `, + examples: [ + ['Store a hero image', 'transloadit storage store ./hero.jpg website/hero.jpg'], + ['Store a content-addressed image', 'transloadit storage store ./hero.jpg website/ --hashed'], + ['Store a directory of originals', 'transloadit storage store ./images/*.jpg website/'], + ], + }) + + files = Option.Rest({ required: 1 }) + destination = Option.String({ required: true }) + hashed = Option.Boolean('--hashed', false, { + description: 'Add an eight-digit content hash to the filename; reuse matching catalog receipts', + }) + overwrite = Option.Boolean('--overwrite', false, { + description: 'Explicitly replace an existing Storage path', + }) + publicDelivery = Option.Boolean('--public', false, { + description: + 'Publish the destination directory recursively, including current and future objects', + }) + protected async run(): Promise { + const file = resolve(this.receipts) + let stored: { receipt?: CliStoredImageReceipt } = {} + let destination = this.destination + let workspace: string | undefined + let saved = false + let published: string | undefined + try { + noticeCliCredentialSource(this.cliConfig, this.output) + if (this.hashed && this.overwrite) + throw new Error( + '--hashed cannot be combined with --overwrite; changed bytes get a new name', + ) + const apiOrigin = this.hashed + ? new URL(this.endpoint ?? this.cliConfig.endpoint ?? 'https://api2.transloadit.com').origin + : undefined + const uploaded = new Map() + if (this.files.length > 1 && !this.destination.endsWith('/')) + throw new Error('Multiple images need a directory destination ending in /') + const inputs = this.files.map((input) => ({ + file: input, + path: this.destination.endsWith('/') + ? `${this.destination}${basename(input)}` + : this.destination, + })) + for (const input of inputs) { + if (file === resolve(input.file)) + throw new Error('The receipts file cannot be the input image') + if (resolve(storageTypesPath(file)) === resolve(input.file)) + throw new Error('The generated declarations file cannot be the input image') + validateStoragePath(input.path) + } + if (!this.hashed && new Set(inputs.map((input) => input.path)).size !== inputs.length) + throw new Error( + 'Input image basenames collide in the destination directory; rename them first', + ) + const publicPrefix = this.publicDelivery + ? normalizeStoragePublicPrefix( + this.destination.slice(0, this.destination.lastIndexOf('/') + 1), + ) + : undefined + let setupPrinted = false + for (const input of inputs) { + let publicImage = false + let unchanged = false + destination = input.path + stored = {} + saved = false + await updateStorageReceipts( + file, + async (receipts, signal) => { + workspace ??= await resolveStorageWorkspace( + this, + this.cliConfig, + receipts?.workspace, + signal, + ) + assertStorageWorkspace(workspace, receipts?.workspace, this.workspace) + signal.throwIfAborted() + if (this.hashed) { + const { md5hash, size } = await hashImageFile(input.file, signal) + const extension = posix.extname(destination) + destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}` + validateStoragePath(destination) + // Explicit workspace overrides leave the catalog alone; still deduplicate this batch. + const candidate = + uploaded.get(destination) ?? + (receipts?.workspace === workspace && Object.hasOwn(receipts.images, destination) + ? receipts.images[destination] + : undefined) + if (candidate !== undefined) { + const previous = hashedReceiptSchema.safeParse(candidate) + if (!previous.success || previous.data.path !== destination) + throw new Error( + `Catalog receipt for ${JSON.stringify(destination)} does not match this file. Restore a verified receipt or choose another destination basename; nothing uploaded.`, + ) + if (previous.data.apiOrigin !== apiOrigin) + throw new Error( + `Cannot verify that ${JSON.stringify(destination)} was stored at ${apiOrigin}. Use --receipts for a separate catalog for this API environment; nothing uploaded.`, + ) + if (previous.data.md5hash !== md5hash || previous.data.size !== size) + throw new Error( + `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`, + ) + stored.receipt = previous.data + unchanged = true + } + } + stored.receipt ??= await this.client.storeImage(input.file, { + path: destination, + signal, + onReceipt: (receipt, expected, assemblyId) => { + const sizeMatches = receipt.size === expected.size + const md5Matches = receipt.md5hash === expected.md5hash + this.output.debug( + JSON.stringify({ + assemblyId, + result: receipt, + input: expected, + sizeMatches, + md5Matches, + }), + ) + if (!sizeMatches || !md5Matches) { + const difference = sizeMatches + ? 'same size, different MD5' + : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes` + this.output.warn( + `Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored image.`, + ) + } + }, + ...(this.overwrite ? { overwrite: true } : {}), + }) + if (this.hashed && !unchanged) + stored.receipt = { ...stored.receipt, source: basename(input.file), apiOrigin } + if (this.hashed) uploaded.set(destination, stored.receipt) + if (receipts !== undefined && receipts.workspace !== workspace) { + this.output.notice( + `Catalog ${this.receipts} was not changed; it belongs to ${receipts.workspace}. Use --receipts for a separate catalog.`, + ) + return undefined + } + publicImage = receipts?.public.some((prefix) => destination.startsWith(prefix)) ?? false + // Replays must not reorder fields or discard application metadata from a saved receipt. + if (unchanged) return receipts + return { + ...receipts, + workspace, + delivery: + receipts?.delivery ?? + storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint), + public: receipts?.public ?? [], + images: { ...receipts?.images, [stored.receipt.path]: stored.receipt }, + } + }, + () => { + saved = true + }, + ) + if (stored.receipt === undefined) throw new Error('Storage did not return a receipt') + if (publicPrefix !== undefined && published === undefined) { + this.output.notice( + `Publishing ${publicPrefix} recursively: all current and future objects under this prefix will be public.`, + ) + await updateStorageReceipts(file, async (previous, signal) => { + const result = await this.client + .publishStoragePrefix(publicPrefix, { signal }) + .catch((cause: unknown) => { + signal.throwIfAborted() + throw new Error(storagePublicError(cause, workspace), { cause }) + }) + published = result.prefix + if (previous === undefined || previous.workspace !== workspace) return undefined + return { ...previous, public: [...new Set([...previous.public, result.prefix])] } + }) + } + const receipt = stored.receipt + publicImage ||= published !== undefined && receipt.path.startsWith(published) + const blur = + saved && publicImage && receipt.thumbhash !== undefined && receipt.hasAlpha !== true + // A foreign catalog says nothing about this destination's policy or rendering setup. + const setupAdvice = + !saved || setupPrinted + ? '' + : `${publicImage ? '' : storageImagePrivateAdvice(receipt.path, this.receipts === defaultStorageCatalog ? undefined : this.receipts)}${await storageImageConfigAdvice()}` + setupPrinted ||= saved + const attribute = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + const src = attribute(receipt.path) + const alt = attribute( + basename(receipt.source ?? receipt.path) + .replace(/\.[^.]+$/, '') + .replaceAll(/[-_]+/g, ' '), + ) + this.output.print( + `${unchanged ? `Unchanged ${receipt.path}; no upload needed.\n` : ''}${saved ? `Saved ${receipt.path} in ${this.receipts}. Commit this catalog and ${storageTypesPath(this.receipts)}.` : `Stored ${receipt.path}; the different-workspace project catalog was left unchanged.`}\nRender it with \nReplace alt with a description (or an empty string for a decorative image).${setupAdvice}`, + receipt, + ) + } + return undefined + } catch (error) { + const failure = ensureError(error) + if (stored.receipt !== undefined) { + this.output.error( + [ + failure.message, + ...(published === undefined + ? [] + : [ + `The server prefix ${published} remains public; use storage unpublish deliberately if needed.`, + ]), + saved + ? `Receipt saved in ${this.receipts}. No further files were uploaded. Do not re-upload this object.` + : 'The object was stored successfully. Do not re-upload; recover the verified receipt below.', + `Receipt: ${JSON.stringify(stored.receipt)}`, + ].join('\n'), + ) + return 1 + } + const recovery = + failure instanceof InconsistentResponseError + ? z + .object({ + assemblyId: z.string().min(1), + receiptCheck: z + .object({ + originalCount: z.number().int().nonnegative(), + metadataValid: z.boolean(), + pathMatches: z.boolean().optional(), + sizeMatches: z.boolean().optional(), + md5Matches: z.boolean().optional(), + }) + .optional(), + }) + .safeParse(failure.cause) + : undefined + if (recovery?.success) { + this.output.debug(JSON.stringify(recovery.data)) + const options = [ + `--receipts ${quoteCliArgument(this.receipts)}`, + ...(this.endpoint ? [`--endpoint ${quoteCliArgument(this.endpoint)}`] : []), + ...(this.workspace ? [`--workspace ${quoteCliArgument(this.workspace)}`] : []), + ].join(' ') + this.output.error( + [ + failure.message, + `Destination: ${JSON.stringify(destination)}`, + `Assembly ID: ${JSON.stringify(recovery.data.assemblyId)}`, + ...(recovery.data.receiptCheck === undefined + ? [] + : [ + 'The Assembly did not return usable receipt metadata. Do not re-upload; inspect Storage and recover its metadata:', + 'The commands below require the Storage read API, not yet enabled in production. Until then, inspect the Assembly in Console and restore a verified catalog receipt.', + // A filename prefix also works for root objects without scanning the workspace. + `transloadit storage ls ${quoteCliArgument(destination)} ${options}`, + `transloadit storage receipts sync ${quoteCliArgument(destination)} ${options}`, + ]), + ].join('\n'), + ) + return 1 + } + if (failure instanceof ApiError && failure.code === 'TRANSLOADIT_STORE_CONFLICT') { + this.output.error( + this.hashed + ? `Storage destination ${JSON.stringify(destination)} already exists, but no matching catalog receipt proves its contents. Restore its catalog receipt or choose another destination basename; the object was not replaced.` + : `Storage destination ${JSON.stringify(destination)} already exists. Choose a fresh name; use --overwrite only if you deliberately want to replace that object.`, + ) + return 1 + } + this.output.error(failure.message) + return 1 + } + } +} + +/** Lists the authenticated workspace through Storage's existing, read-only S3 surface. */ +export class StorageListCommand extends UnauthenticatedCommand { + static override paths = [['storage', 'ls']] + static override usage = Command.Usage({ + category: 'Storage', + description: + 'List stored paths and sizes; include ETags with --json, without creating an Assembly', + details: + 'Uses an Auth Key with read or dam:write scope and the S3-compatible Storage API. Infers the workspace from ListBuckets unless --workspace is supplied. --endpoint accepts the API origin, not a bucket URL.', + examples: [['List website images', 'transloadit storage ls website/']], + }) + + prefix = Option.String({ required: true }) + workspace = Option.String('--workspace', { + description: 'Explicit workspace slug (otherwise discovered from this Auth Key)', + }) + receipts = Option.String('--receipts', defaultStorageCatalog, { + description: 'Project catalog whose workspace must match the selected credentials', + }) + + protected async run(): Promise { + try { + const catalog = await readStorageCatalog(this.receipts) + const objects = await withStorageS3( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: catalog?.workspace, + }, + (client, workspace) => listStorageObjects(client, workspace, this.prefix), + 'Storage listing', + this.output, + ) + this.output.print( + objects.length === 0 + ? 'No stored objects match this prefix.' + : objects + .map((object) => `${JSON.stringify(object.path)}\t${object.size} bytes`) + .join('\n'), + objects, + ) + return undefined + } catch (error) { + this.output.error(ensureError(error).message) + return 1 + } + } +} + +const dimensionSchema = z + .string() + .regex(/^[1-9]\d*$/) + .transform(Number) + .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)) +const imageMetadataSchema = z.object({ + 'dam-width': dimensionSchema, + 'dam-height': dimensionSchema, +}) + +const uploadEvidenceSchema = z.object({ + md5hash: z.string().regex(/^[a-f0-9]{32}$/i), + asset_id: z.string().min(1).optional(), + size: z.number().int().nonnegative().optional(), + thumbhash: z.string().max(48).optional(), + hasAlpha: z.boolean().optional(), + source: z.string().optional(), + apiOrigin: z.string().url().optional(), +}) + +const hashedReceiptSchema = uploadEvidenceSchema.required({ asset_id: true, size: true }).extend({ + path: z.string(), + width: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}) + +function md5FromHead(head: HeadObjectCommandOutput): string | undefined { + // S3's multipart, SSE-KMS and SSE-C ETags are not original-byte MD5 checksums. + if ( + head.SSECustomerAlgorithm !== undefined || + (head.ServerSideEncryption !== undefined && head.ServerSideEncryption !== 'AES256') + ) + return undefined + const etag = head.ETag + const hash = etag?.startsWith('"') && etag.endsWith('"') ? etag.slice(1, -1) : etag + return hash !== undefined && /^[a-f0-9]{32}$/i.test(hash) ? hash.toLowerCase() : undefined +} + +/** Recovers rendering metadata using signed List + HEAD only, without downloading originals. */ +export class StorageReceiptsSyncCommand extends UnauthenticatedCommand { + static override paths = [['storage', 'receipts', 'sync']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'Rebuild saved rendering metadata from the Storage catalog', + details: ` + Uses the same Auth Key and workspace discovery as storage ls, with dam:write scope to also + recover the server's declared public prefixes. Requires the Storage S3 read API. + Adds or refreshes matched paths; never prunes unmatched local entries. Keeps existing upload + asset_id/size only when the HEAD MD5 matches; otherwise replaces with rendering metadata. + All listed images must expose valid dam-width/dam-height metadata. Any failure preserves the + previous file. No Assembly, original download or remote write is performed. + `, + examples: [['Recover website images', 'transloadit storage receipts sync website/']], + }) + + prefix = Option.String({ required: true }) + workspace = Option.String('--workspace', { + description: 'Explicit workspace slug (otherwise discovered from this Auth Key)', + }) + receipts = Option.String('--receipts', defaultStorageCatalog, { + description: 'JSON rendering catalog to update atomically (default: transloadit.images.json)', + }) + + protected async run(): Promise { + try { + let count = 0 + let synced: Record = {} + let catalogUpdated = false + const config = resolveCliConfig() + await updateStorageReceipts(resolve(this.receipts), async (previous, signal) => { + let actualWorkspace: string | undefined + synced = await withStorageS3( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: previous?.workspace, + signal, + }, + async (client, workspace, endpoint) => { + actualWorkspace = workspace + const objects = await listStorageObjects(client, workspace, this.prefix, signal) + const paths = new Set() + for (const { path } of objects) { + try { + validateStoragePath(path) + } catch (error) { + throw new Error( + `Storage image ${JSON.stringify(path)} has an unsupported path: ${ensureError(error).message}`, + { cause: error }, + ) + } + if (!path.startsWith(this.prefix) || paths.has(path)) + throw new Error( + `Storage returned a duplicate path or one outside the requested prefix: ${JSON.stringify(path)}`, + ) + paths.add(path) + } + const { HeadObjectCommand } = await import('@aws-sdk/client-s3') + const entries = await pMap( + objects, + async ({ path }) => { + const head = await client + .send(new HeadObjectCommand({ Bucket: workspace, Key: path }), { + abortSignal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]), + }) + .catch((error: unknown) => { + signal.throwIfAborted() + if (storageS3ConnectionErrorSchema.safeParse(error).success) + throw new Error( + `Storage HEAD for ${JSON.stringify(path)} timed out or lost its connection. Check the Storage endpoint and retry the sync.`, + { cause: error }, + ) + const remote = storageS3ErrorSchema.safeParse(error) + const status = remote.success ? remote.data.$metadata.httpStatusCode : undefined + throw new Error( + `Storage HEAD failed for ${JSON.stringify(path)}${status === undefined ? '' : ` (HTTP ${status})`}. ${status === 403 ? storageS3ReadAdvice(endpoint, status) : 'The object may have changed or access may be denied; check it and retry the sync.'}`, + { cause: error }, + ) + }) + const dimensions = imageMetadataSchema.safeParse(head.Metadata) + if (!dimensions.success) + throw new Error( + `Storage image ${JSON.stringify(path)} needs positive integer dam-width and dam-height metadata. Select an image-only prefix and backfill missing catalog dimensions before retrying.`, + ) + const md5hash = md5FromHead(head) + const evidence = uploadEvidenceSchema.safeParse( + previous?.workspace === workspace && Object.hasOwn(previous.images, path) + ? previous.images[path] + : undefined, + ) + const retained = + md5hash !== undefined && + evidence.success && + evidence.data.md5hash.toLowerCase() === md5hash + ? evidence.data + : {} + return [ + path, + { + ...retained, + path, + width: dimensions.data['dam-width'], + height: dimensions.data['dam-height'], + ...(md5hash === undefined ? {} : { md5hash }), + }, + ] + }, + { concurrency: 5, signal }, + ) + count = entries.length + return Object.fromEntries(entries) + }, + 'Storage receipt sync', + this.output, + config, + ) + if (actualWorkspace === undefined) throw new Error('Storage did not identify a workspace') + if (previous !== undefined && previous.workspace !== actualWorkspace) { + this.output.notice( + `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}. Use --receipts for a separate catalog.`, + ) + return undefined + } + // Recover policy with the same key/endpoint as the S3 reads, never an unrelated bearer + // token or a folder-name guess. Neither half is committed if this read fails. + if (config.credentials === undefined) throw new Error('Storage credentials are missing') + const policyClient = new Transloadit({ + ...config.credentials, + endpoint: new URL( + this.endpoint ?? config.credentialsEndpoint ?? 'https://api2.transloadit.com', + ).origin, + maxRetries: 0, + }) + const policy = await policyClient + .listPublicStoragePrefixes({ + signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]), + }) + .catch((error: unknown) => { + signal.throwIfAborted() + throw new Error( + 'Recovery incomplete: could not read the server public prefixes. Check the API endpoint and Auth Key dam:write scope, then retry. The existing catalog was preserved.', + { cause: error }, + ) + }) + catalogUpdated = true + if (policy.public_prefixes.length === 0) + this.output.notice( + 'No public prefixes are declared on the server. For public delivery, deliberately publish a directory with storage publish; otherwise configure authorize for private images. Sync never publishes files.', + ) + return { + ...previous, + workspace: actualWorkspace, + delivery: + previous?.delivery ?? + storageCatalogDelivery(this.endpoint ?? config.credentialsEndpoint), + public: policy.public_prefixes.map(({ prefix }) => prefix), + images: { ...previous?.images, ...synced }, + } + }) + this.output.print( + catalogUpdated + ? `Synced ${count} rendering receipts and public policy to ${this.receipts}. Unmatched entries were preserved. Commit this file before building.` + : `Read ${count} rendering receipts. Catalog unchanged because it belongs to another workspace.`, + synced, + ) + return undefined + } catch (error) { + this.output.error(ensureError(error).message) + return 1 + } + } +} diff --git a/packages/node/src/cli/deviceLogin.ts b/packages/node/src/cli/deviceLogin.ts new file mode 100644 index 00000000..517a45d6 --- /dev/null +++ b/packages/node/src/cli/deviceLogin.ts @@ -0,0 +1,226 @@ +import type { CliKeySecretCredentials } from './helpers.ts' +import type { IOutputCtl } from './OutputCtl.ts' + +import { hostname } from 'node:os' +import { setTimeout as delay } from 'node:timers/promises' + +import { execa } from 'execa' +import got from 'got' +import { z } from 'zod' + +import { cliSignatureAlgorithmSchema } from './helpers.ts' + +const deviceSchema = z.object({ + ok: z.literal('CLI_DEVICE_AUTHORIZATION_CREATED'), + device_code: z.string().min(1).max(4096), + user_code: z + .string() + .regex(/^[BCDFGHJKLMNPQRSTVWXZ23456789]{4}-[BCDFGHJKLMNPQRSTVWXZ23456789]{4}$/), + verification_url: z.string().url(), + // The agreed device contract is bounded to 15 minutes; reject incompatible server responses. + expires_in: z.number().int().positive().max(900), + interval: z.number().int().positive().max(900), +}) +const authorizedSchema = z.object({ + ok: z.literal('CLI_DEVICE_AUTHORIZED'), + signature_algo: cliSignatureAlgorithmSchema.nullable(), + workspace: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/), + auth_key: z + .string() + .min(1) + .max(4096) + .regex(/^[^\r\n\0]+$/), + auth_secret: z + .string() + .min(1) + .max(4096) + .regex(/^[^\r\n\0]+$/), + auth_key_id: z + .string() + .regex(/^[a-zA-Z0-9_-]{1,128}$/) + .optional(), + description: z + .string() + .max(512) + .regex(/^[^\r\n\0]*$/) + .optional(), +}) +const pendingSchema = z.object({ + ok: z.literal('CLI_DEVICE_AUTHORIZATION_PENDING'), + expires_in: z.number().int().nonnegative(), +}) +const errorSchema = z.object({ error: z.string() }) +const expiredMessage = + 'Device authorization expired or was already used. Run transloadit auth login again.' + +/** Browser-approved credentials; the one-time device code never leaves this module. */ +export interface DeviceLoginCredentials extends CliKeySecretCredentials { + workspace: string + authKeyId?: string + description?: string +} + +/** Obtain a combined Auth Key using the API's single-use device authorization contract. */ +export async function deviceLogin( + endpoint: string, + output: IOutputCtl, + noBrowser: boolean, +): Promise { + const cancellation = new AbortController() + const cancel = (): void => cancellation.abort() + process.once('SIGINT', cancel) + let expired: AbortSignal | undefined + let heartbeat: ReturnType | undefined + try { + const response = await got + .post(`${endpoint}/cli/device_authorizations`, { + form: { client: 'transloadit-cli', hostname: hostname() }, + responseType: 'json', + retry: { limit: 0 }, + followRedirect: false, + timeout: { request: 10_000 }, + signal: cancellation.signal, + }) + .catch((cause: unknown) => { + throw new Error( + 'Could not start browser login. Check the API endpoint and retry; use auth login --stdin for an existing Auth Key.', + { cause }, + ) + }) + const parsed = deviceSchema.safeParse(response.body) + if (!parsed.success) + throw new Error('The API returned an invalid device authorization; nothing was saved') + const device = parsed.data + const target = new URL(device.verification_url) + if ( + target.username || + target.password || + target.hash || + (target.protocol !== 'https:' && + !( + target.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) + )) + ) + throw new Error('The API returned an unsafe verification URL; nothing was opened or saved') + expired = AbortSignal.timeout(device.expires_in * 1000) + const signal = AbortSignal.any([cancellation.signal, expired]) + const deadline = performance.now() + device.expires_in * 1000 + heartbeat = setInterval(() => { + const minutes = Math.ceil((deadline - performance.now()) / 60_000) + if (signal.aborted || minutes <= 0) return + output.notice( + `Still waiting for approval, ${minutes} minute${minutes === 1 ? '' : 's'} left. Use the verification URL printed above.`, + ) + }, 60_000) + heartbeat.unref() + output.print(`Enter code ${device.user_code} at ${target.href}`, { + user_code: device.user_code, + verification_url: target.href, + }) + const opener = + process.platform === 'darwin' + ? 'open' + : process.platform === 'linux' + ? 'xdg-open' + : process.platform === 'win32' + ? 'cmd' + : undefined + // On platforms without an opener, the printed URL is the manual approval path. + if (!noBrowser && opener !== undefined) { + // xdg-open may live as long as the browser; launching it must not delay or cancel polling. + // cmd parses metacharacters even with shell:false. A quoted, single-pass env expansion + // keeps &, %, and ! in the URL out of command syntax; disable delayed expansion/AutoRun. + const browser = execa( + opener, + opener === 'cmd' + ? ['/d', '/v:off', '/c', 'start', '""', '"%TRANSLOADIT_BROWSER_URL%"'] + : [target.href], + { + shell: false, + ...(opener === 'cmd' + ? { windowsVerbatimArguments: true, env: { TRANSLOADIT_BROWSER_URL: target.href } } + : {}), + stdio: 'ignore', + detached: true, + cleanup: false, + }, + ) + browser.unref() + void browser.catch(() => { + // A wrapper's later failure cannot prove whether its page opened or invalidate login. + output.warn( + 'The browser opener reported an error. If the page did not open, use the verification URL printed above.', + ) + }) + } + let intervalMs = device.interval * 1000 + while (true) { + await delay(intervalMs, undefined, { signal }) + const token = await got + .post(`${endpoint}/cli/device_authorizations/token`, { + form: { device_code: device.device_code }, + responseType: 'json', + retry: { limit: 0 }, + followRedirect: false, + throwHttpErrors: false, + timeout: { request: 10_000 }, + signal, + }) + .catch((cause: unknown) => { + throw new Error( + 'Could not finish browser login. Check connectivity and run transloadit auth login again.', + { cause }, + ) + }) + const error = errorSchema.safeParse(token.body) + if ( + token.statusCode === 403 && + error.success && + error.data.error === 'CLI_DEVICE_AUTHORIZATION_DENIED' + ) + throw new Error('The login was denied in the browser.') + if (token.statusCode === 429 || (error.success && error.data.error === 'slow_down')) { + const seconds = Number(token.headers['retry-after']) + // Longer waits are pointless after the authorization deadline and can overflow Node's + // timer range into 1 ms, accidentally hammering an already rate-limited API. + intervalMs = Math.min( + device.expires_in * 1000, + Math.max(intervalMs + 5000, Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0), + ) + continue + } + if (error.success && error.data.error === 'CLI_DEVICE_AUTHORIZATION_NOT_FOUND') + throw new Error(expiredMessage) + if (token.statusCode < 200 || token.statusCode >= 300) + throw new Error( + 'Browser login was refused. Run transloadit auth login again; nothing was saved.', + ) + const pending = pendingSchema.safeParse(token.body) + if (pending.success) { + if (pending.data.expires_in === 0) throw new Error(expiredMessage) + continue + } + const authorized = authorizedSchema.safeParse(token.body) + if (!authorized.success) + throw new Error('The API returned an invalid login result; nothing was saved') + signal.throwIfAborted() + return { + authKey: authorized.data.auth_key, + authSecret: authorized.data.auth_secret, + signatureAlgorithm: authorized.data.signature_algo ?? undefined, + workspace: authorized.data.workspace, + authKeyId: authorized.data.auth_key_id, + description: authorized.data.description, + } + } + } catch (cause) { + if (cancellation.signal.aborted) + throw new Error('Login canceled; nothing was saved.', { cause }) + if (expired?.aborted) throw new Error(expiredMessage, { cause }) + throw cause + } finally { + clearInterval(heartbeat) + process.off('SIGINT', cancel) + } +} diff --git a/packages/node/src/cli/helpers.ts b/packages/node/src/cli/helpers.ts index 2876794d..e9d7a70b 100644 --- a/packages/node/src/cli/helpers.ts +++ b/packages/node/src/cli/helpers.ts @@ -1,15 +1,24 @@ import type { Readable } from 'node:stream' +import type { IOutputCtl } from './OutputCtl.ts' + import fs from 'node:fs' import fsp from 'node:fs/promises' -import { homedir } from 'node:os' +import { homedir, userInfo } from 'node:os' import path from 'node:path' import { parse as parseDotenv } from 'dotenv' +import { z } from 'zod' import { isAPIError } from './types.ts' -export type CliKeySecretCredentials = { authKey: string; authSecret: string } +/** API signing algorithms supported by CLI credentials and device authorization. */ +export const cliSignatureAlgorithmSchema = z.enum(['sha1', 'sha256', 'sha384', 'sha512']) +export type CliKeySecretCredentials = { + authKey: string + authSecret: string + signatureAlgorithm?: z.infer +} export type CliAuthToken = { authToken: string } export type CliAuth = CliKeySecretCredentials | CliAuthToken type CliEnvSource = { @@ -20,7 +29,9 @@ type CliEnvSource = { let loadedProjectDotenvPath: string | undefined let projectDotenvInjectedValues: Record | undefined let projectDotenvPreviousValues: Record | undefined -let shellEnvBeforeProjectDotenv: Record | undefined +let shellEnvBeforeProjectDotenv: + | { values: Record; homeDirectory: string } + | undefined type LoadCliEnvSourcesResult = { loadError?: string @@ -30,8 +41,16 @@ type LoadCliEnvSourcesResult = { export type ResolvedCliConfig = { auth?: CliAuth + authSource?: string + authWorkspace?: string + authWorkspaceVerified?: boolean credentials?: CliKeySecretCredentials + credentialsSource?: string credentialsEndpoint?: string + credentialsWorkspace?: string + credentialsWorkspaceVerified?: boolean + credentialsAuthKeyId?: string + credentialsDescription?: string endpoint?: string loadError?: string } @@ -41,13 +60,26 @@ function normalizeEnvValue(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined } -function getConfiguredCredentialsFilePath(): string { - const configuredPath = normalizeEnvValue(process.env.TRANSLOADIT_CREDENTIALS_FILE) +function credentialHomeDirectory(): string { + // Node trusts HOME verbatim; an empty or relative default must never put secrets in a repo. + const home = homedir() + return path.isAbsolute(home) ? home : userInfo().homedir +} + +/** Login and its env scaffold accept only a shell path override; ordinary reads retain merged lookup. */ +export function getConfiguredCredentialsFilePath(source: 'shell' | 'merged' = 'merged'): string { + const values = source === 'shell' ? getShellEnvValues() : process.env + const configuredPath = normalizeEnvValue(values.TRANSLOADIT_CREDENTIALS_FILE) if (configuredPath != null) { return path.resolve(configuredPath) } - return path.join(homedir(), '.transloadit', 'credentials') + // HOME/USERPROFILE from project dotenv must not redirect the login's default destination. + const shellHome = + source === 'shell' && loadedProjectDotenvPath === getProjectDotenvPath() + ? shellEnvBeforeProjectDotenv?.homeDirectory + : undefined + return path.join(shellHome ?? credentialHomeDirectory(), '.transloadit', 'credentials') } function getProjectDotenvPath(): string { @@ -112,7 +144,10 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined { const projectDotenvPath = getProjectDotenvPath() if (loadedProjectDotenvPath !== projectDotenvPath) { restoreProjectDotenvFromProcessEnv() - shellEnvBeforeProjectDotenv = { ...process.env } + shellEnvBeforeProjectDotenv = { + values: { ...process.env }, + homeDirectory: credentialHomeDirectory(), + } loadedProjectDotenvPath = projectDotenvPath } @@ -144,7 +179,7 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined { function getShellEnvValues(): Record { if (loadedProjectDotenvPath === getProjectDotenvPath() && shellEnvBeforeProjectDotenv != null) { - return shellEnvBeforeProjectDotenv + return shellEnvBeforeProjectDotenv.values } return { ...process.env } @@ -214,12 +249,23 @@ function getSourceValue(source: CliEnvSource, keys: string[]): string | undefine return undefined } -function getSourceCredentials(source: CliEnvSource): CliKeySecretCredentials | undefined { +function getSourceCredentials( + source: CliEnvSource, +): CliKeySecretCredentials | { loadError: string } | undefined { const authKey = getSourceValue(source, ['TRANSLOADIT_KEY', 'TRANSLOADIT_AUTH_KEY']) const authSecret = getSourceValue(source, ['TRANSLOADIT_SECRET', 'TRANSLOADIT_AUTH_SECRET']) if (authKey == null || authSecret == null) return undefined - return { authKey, authSecret } + const algorithm = cliSignatureAlgorithmSchema + .optional() + .safeParse(getSourceValue(source, ['TRANSLOADIT_SIGNATURE_ALGORITHM'])) + if (!algorithm.success) + return { loadError: 'Unsupported TRANSLOADIT_SIGNATURE_ALGORITHM in CLI credentials' } + return { + authKey, + authSecret, + ...(algorithm.data === undefined ? {} : { signatureAlgorithm: algorithm.data }), + } } function getSourceAuthToken(source: CliEnvSource): CliAuthToken | undefined { @@ -240,31 +286,119 @@ function resolveEndpointForSource( return getSourceValue(source, ['TRANSLOADIT_ENDPOINT']) } -export function resolveCliConfig(): ResolvedCliConfig { +function isSavedLoginSource(source: CliEnvSource): boolean { + return ( + source.name === 'credentialsFile' && + getConfiguredCredentialsFilePath() === getConfiguredCredentialsFilePath('shell') + ) +} + +function hasVerifiedWorkspace(source: CliEnvSource, shell: CliEnvSource): boolean { + // A project-selected file or changed endpoint cannot inherit the login-time ownership proof. + return ( + isSavedLoginSource(source) && + getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true' && + resolveEndpointForSource(source, shell) === getSourceValue(source, ['TRANSLOADIT_ENDPOINT']) + ) +} + +function credentialSourceName(source: CliEnvSource, shell: CliEnvSource, auth: CliAuth): string { + if (source.name === 'credentialsFile') + return isSavedLoginSource(source) ? 'saved login' : 'project-selected credentials file' + const fields = + 'authToken' in auth + ? [['TRANSLOADIT_AUTH_TOKEN']] + : [ + ['TRANSLOADIT_KEY', 'TRANSLOADIT_AUTH_KEY'], + ['TRANSLOADIT_SECRET', 'TRANSLOADIT_AUTH_SECRET'], + ] + const fromShell = fields.filter((aliases) => { + const name = aliases.find((name) => normalizeEnvValue(source.values[name]) !== undefined) + return ( + name !== undefined && + normalizeEnvValue(shell.values[name]) === normalizeEnvValue(source.values[name]) + ) + }).length + if (fromShell === fields.length) return 'shell environment' + return fromShell === 0 ? 'project .env' : 'shell environment + project .env' +} + +/** Quotes a value in the CLI's copyable POSIX-shell commands without expanding user input. */ +export function quoteCliArgument(value: string): string { + return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'` +} + +/** Names credential overrides without exposing values or claiming verified ownership. */ +export function noticeCliCredentialSource( + config: ResolvedCliConfig, + output: Pick | undefined, + kind: 'auth' | 'credentials' = 'auth', +): void { + const source = kind === 'auth' ? config.authSource : config.credentialsSource + if (source === undefined || source === 'saved login') return + const workspace = kind === 'auth' ? config.authWorkspace : config.credentialsWorkspace + const label = + workspace !== undefined && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(workspace) + ? `workspace declared as ${workspace}` + : 'workspace not declared' + output?.notice( + `Credentials: ${source} (${label}). This override takes precedence over the saved login.`, + ) +} + +export function resolveCliConfig(source: 'all' | 'login' = 'all'): ResolvedCliConfig { + if (source === 'login') { + // Match auth login's destination and keep its key, workspace, algorithm and endpoint together. + // Project dotenv and stale shell credentials must not redirect this onboarding operation. + const saved = readEnvFile(getConfiguredCredentialsFilePath('shell')) + if (!saved?.ok) return saved === null ? {} : { loadError: saved.error } + const credentials = getSourceCredentials(saved.source) + if (credentials !== undefined && 'loadError' in credentials) return credentials + const endpoint = getSourceValue(saved.source, ['TRANSLOADIT_ENDPOINT']) + return { + auth: credentials, + authSource: 'saved login', + authWorkspace: getSourceValue(saved.source, ['TRANSLOADIT_WORKSPACE']), + authWorkspaceVerified: + getSourceValue(saved.source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true', + credentials, + credentialsSource: 'saved login', + credentialsWorkspace: getSourceValue(saved.source, ['TRANSLOADIT_WORKSPACE']), + credentialsWorkspaceVerified: + getSourceValue(saved.source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true', + credentialsAuthKeyId: getSourceValue(saved.source, ['TRANSLOADIT_AUTH_KEY_ID']), + credentialsDescription: getSourceValue(saved.source, ['TRANSLOADIT_AUTH_KEY_DESCRIPTION']), + credentialsEndpoint: endpoint, + endpoint, + } + } const { loadError, shellEnvSource, sources } = loadCliEnvSources() let auth: CliAuth | undefined let authSource: CliEnvSource | undefined let credentials: CliKeySecretCredentials | undefined let credentialsSource: CliEnvSource | undefined + let credentialsError: string | undefined for (const source of sources) { - if (auth == null) { - const authToken = getSourceAuthToken(source) - if (authToken != null) { - auth = authToken - authSource = source - } else { - const sourceCredentials = getSourceCredentials(source) - if (sourceCredentials != null) { - auth = sourceCredentials - authSource = source - } - } + if (auth != null && credentials != null) break + const authToken = getSourceAuthToken(source) + if (auth == null && authToken != null) { + auth = authToken + authSource = source + } + const sourceCredentials = getSourceCredentials(source) + if (sourceCredentials !== undefined && 'loadError' in sourceCredentials) { + // Signing failure must not discard a valid bearer token or silently choose a different key. + credentialsError = sourceCredentials.loadError + break + } + if (auth == null && sourceCredentials != null) { + auth = sourceCredentials + authSource = source } if (credentials != null) continue - const sourceCredentials = getSourceCredentials(source) if (sourceCredentials != null) { credentials = sourceCredentials credentialsSource = source @@ -272,15 +406,32 @@ export function resolveCliConfig(): ResolvedCliConfig { } return { - ...(auth != null ? { auth } : {}), + ...(auth != null && authSource != null + ? { + auth, + authSource: credentialSourceName(authSource, shellEnvSource, auth), + authWorkspace: getSourceValue(authSource, ['TRANSLOADIT_WORKSPACE']), + authWorkspaceVerified: hasVerifiedWorkspace(authSource, shellEnvSource), + } + : {}), ...(credentials != null ? { credentials } : {}), ...(authSource != null ? { endpoint: resolveEndpointForSource(authSource, shellEnvSource) } : {}), ...(credentialsSource != null - ? { credentialsEndpoint: resolveEndpointForSource(credentialsSource, shellEnvSource) } + ? { + credentialsEndpoint: resolveEndpointForSource(credentialsSource, shellEnvSource), + credentialsSource: + credentials === undefined + ? undefined + : credentialSourceName(credentialsSource, shellEnvSource, credentials), + credentialsWorkspace: getSourceValue(credentialsSource, ['TRANSLOADIT_WORKSPACE']), + credentialsWorkspaceVerified: hasVerifiedWorkspace(credentialsSource, shellEnvSource), + } + : {}), + ...(credentialsError != null || loadError != null + ? { loadError: credentialsError ?? loadError } : {}), - ...(loadError != null ? { loadError } : {}), } } diff --git a/packages/node/src/cli/secretInput.ts b/packages/node/src/cli/secretInput.ts new file mode 100644 index 00000000..b8cd15ab --- /dev/null +++ b/packages/node/src/cli/secretInput.ts @@ -0,0 +1,12 @@ +import { parse } from 'dotenv' + +/** Preserve opaque punctuation and literal escapes when serializing a CLI credential file. */ +export function quoteCredential(value: string): string { + for (const quote of ["'", '"', '`']) { + const quoted = `${quote}${value}${quote}` + if (parse(`value=${quoted}`).value === value) return quoted + } + throw new Error( + 'This credential cannot be represented safely in a dotenv file; nothing was saved', + ) +} diff --git a/packages/node/src/cli/storagePublic.ts b/packages/node/src/cli/storagePublic.ts new file mode 100644 index 00000000..4394803d --- /dev/null +++ b/packages/node/src/cli/storagePublic.ts @@ -0,0 +1,15 @@ +import { ApiError } from '../ApiError.ts' + +/** Actionable CLI advice without printing remote response bodies or credentials. */ +export function storagePublicError(error: unknown, workspace?: string): string { + if (error instanceof ApiError && error.code === 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY') { + const slug = + workspace !== undefined && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(workspace) + ? workspace + : '' + return `Enable Smart CDN on an Auth Key at https://transloadit.com/c/${slug}/template-credentials/ and retry. The same key can serve Assemblies and Smart CDN.` + } + if (error instanceof TypeError) return error.message + // Keep unrecognized server errors out of CLI output; only known codes select specific advice. + return 'Could not update or list public Storage prefixes. Check the API endpoint and Auth Key dam:write scope, then retry.' +} diff --git a/packages/node/src/cli/storageReceipts.ts b/packages/node/src/cli/storageReceipts.ts new file mode 100644 index 00000000..a81469ef --- /dev/null +++ b/packages/node/src/cli/storageReceipts.ts @@ -0,0 +1,232 @@ +import type { FileHandle } from 'node:fs/promises' + +import { randomUUID } from 'node:crypto' +import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' + +import { z } from 'zod' + +import { normalizeStoragePublicPrefix } from '../storagePublicPrefixes.ts' +import { ensureError, isErrnoException } from './types.ts' + +// Keep every JSON key verbatim: a Storage filename may be "__proto__", which z.record strips. +const receiptsSchema = z.custom>( + (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value), +) +const deliveryParameterSchema = z.union([z.string(), z.number(), z.boolean()]) + +/** Project identity and rendering metadata (including optional preview pixels), without credentials. */ +export const storageCatalogSchema = z.object({ + workspace: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/), + public: z.array( + z.string().refine((prefix) => { + try { + return normalizeStoragePublicPrefix(prefix) === prefix + } catch { + return false + } + }, 'Expected a normalized public directory'), + ), + images: receiptsSchema, + delivery: z + .object({ + baseUrl: z.string().optional(), + urlParams: z + .record(z.string(), z.union([deliveryParameterSchema, z.array(deliveryParameterSchema)])) + .optional(), + }) + .optional(), +}) +export type StorageProjectCatalog = z.infer + +/** Default project catalog; --receipts can select a separate project explicitly. */ +export const defaultStorageCatalog = 'transloadit.images.json' + +/** Non-production login origins are explicit transport overrides, never rendering credentials. */ +export function storageCatalogDelivery(endpoint?: string): StorageProjectCatalog['delivery'] { + if (!endpoint) return undefined + const { origin } = new URL(endpoint) + if (origin === 'https://api2.transloadit.com') return undefined + return { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } } +} + +const dimensionsSchema = z.object({ + width: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}) + +const typesHeader = + '// Generated by transloadit storage. Commit this file with the image catalog; do not edit.' + +/** The single conventional type-registration file beside a CLI catalog. */ +export function storageTypesPath(catalog: string): string { + return join(dirname(catalog), 'transloadit-images.d.ts') +} + +function catalogTypes(catalog: StorageProjectCatalog): string { + // Canonical LF keeps generated output deterministic; Git controls checkout-specific EOL conversion. + const properties = Object.entries(catalog.images) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .flatMap(([path, receipt]) => { + const dimensions = dimensionsSchema.safeParse(receipt) + // Recovery preserves unmatched legacy records verbatim; only complete image geometry + // participates in the generated type catalog. The renderer still validates every source. + if (!dimensions.success) return [] + const { width, height } = dimensions.data + const name = JSON.stringify(path) + return ` ${name}: { path: ${name}; width: ${width}; height: ${height}; thumbhash?: string; hasAlpha?: boolean }` + }) + return [ + typesHeader, + "import '@transloadit/img/next'", + '', + "declare module '@transloadit/img/next' {", + ' interface RegisteredStorageImages {', + ...properties, + ' }', + '}', + '', + ].join('\n') +} + +async function readReceipts( + file: string, +): Promise<{ catalog?: StorageProjectCatalog; mode?: number }> { + try { + const info = await lstat(file) + if (!info.isFile()) throw new Error('Expected a regular JSON file, not a symlink or directory') + return { + catalog: storageCatalogSchema.parse(JSON.parse(await readFile(file, 'utf8'))), + mode: info.mode & 0o777, + } + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') return {} + const reason = + error instanceof SyntaxError + ? 'invalid JSON' + : error instanceof z.ZodError + ? 'expected a project catalog with workspace, public and images' + : ensureError(error).message + throw new Error(`Cannot read receipts ${JSON.stringify(file)}: ${reason}`, { cause: error }) + } +} + +/** Read a project binding without creating a catalog or making a network request. */ +export async function readStorageCatalog(file: string): Promise { + return (await readReceipts(file)).catalog +} + +/** A workspace override selects another workspace, never another key or implicit project rebinding. */ +export function assertStorageWorkspace(actual: string, project?: string, requested?: string): void { + if (requested !== undefined && requested !== actual) + throw new Error(`Selected credentials belong to ${actual}, not ${requested}. Nothing uploaded.`) + if (project !== undefined && project !== actual && requested === undefined) + throw new Error( + `Project uses ${project}; the selected credentials belong to ${actual}. Nothing uploaded.`, + ) +} + +/** + * Serializes CLI receipt writers and replaces a catalog only after the complete update succeeds. + * onCheckpoint observes a completed rename even if interruption or cleanup subsequently fails. + */ +export async function updateStorageReceipts( + file: string, + update: ( + catalog: StorageProjectCatalog | undefined, + signal: AbortSignal, + ) => Promise, + onCheckpoint?: () => void, +): Promise { + const lockPath = `${file}.lock` + const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`) + const typesFile = storageTypesPath(file) + if (basename(file) === 'transloadit-images.d.ts') + throw new Error( + 'The catalog cannot use the generated declarations filename transloadit-images.d.ts', + ) + const typesTemporary = `${temporary}.d.ts` + const cancellation = new AbortController() + const cancel = (): void => + cancellation.abort( + new Error('Storage command canceled. Check Storage before retrying a write.'), + ) + // The first interrupt is cooperative; a forced exit/crash still leaves a lock for inspection. + process.once('SIGINT', cancel) + process.once('SIGTERM', cancel) + let lock: FileHandle | undefined + let retainTemporary = false + try { + lock = await open(lockPath, 'wx', 0o600).catch((error: unknown) => { + if (isErrnoException(error) && error.code === 'EEXIST') { + throw new Error( + `The receipts file is locked by another storage store or receipts sync. Remove ${lockPath} only after confirming no writer is running.`, + { cause: error }, + ) + } + throw error + }) + cancellation.signal.throwIfAborted() + const { catalog, mode } = await readReceipts(file) + const typesInfo = await lstat(typesFile).catch((error: unknown) => { + if (isErrnoException(error) && error.code === 'ENOENT') return undefined + throw error + }) + if (typesInfo !== undefined && !typesInfo.isFile()) + throw new Error(`Expected a regular generated types file: ${typesFile}`) + if ( + typesInfo !== undefined && + (await readFile(typesFile, 'utf8')).split(/\r?\n/, 1)[0] !== typesHeader + ) + throw new Error( + `Refusing to overwrite handwritten declarations in ${typesFile}. Move them to a separate file before retrying.`, + ) + const updated = await update(catalog, cancellation.signal) + // An explicit one-off workspace override must not mix two workspaces in one catalog. + if (updated === undefined) { + cancellation.signal.throwIfAborted() + return + } + // Once a remote write returned a receipt, finish its atomic checkpoint even if interrupted. + // New catalogs are ordinary source files: let the kernel apply umask, without reading it. + await writeFile(temporary, `${JSON.stringify(updated, null, 2)}\n`, { + flag: 'wx', + mode: mode === undefined ? 0o666 : 0o600, + }) + retainTemporary = true + if (mode !== undefined) await chmod(temporary, mode) + await writeFile(typesTemporary, catalogTypes(updated), { flag: 'wx', mode: 0o666 }) + if (typesInfo !== undefined) await chmod(typesTemporary, typesInfo.mode & 0o777) + await rename(temporary, file) + retainTemporary = false + onCheckpoint?.() + // The catalog is the authoritative checkpoint. A failure replacing derived types must + // never discard a verified upload; sync can regenerate declarations without another write. + await rename(typesTemporary, typesFile).catch((error: unknown) => { + throw new Error( + `Catalog saved, but cannot update ${typesFile}. Run storage receipts sync to regenerate types without re-uploading.`, + { cause: error }, + ) + }) + cancellation.signal.throwIfAborted() + } catch (error) { + if (retainTemporary) + throw new Error( + `${ensureError(error).message}\nComplete catalog retained at ${JSON.stringify(temporary)}.`, + { cause: error }, + ) + throw error + } finally { + try { + if (lock !== undefined) { + await lock.close() + await rm(lockPath, { force: true }) + if (!retainTemporary) await rm(temporary, { force: true }) + await rm(typesTemporary, { force: true }) + } + } finally { + process.off('SIGINT', cancel) + process.off('SIGTERM', cancel) + } + } +} diff --git a/packages/node/src/cli/storageS3.ts b/packages/node/src/cli/storageS3.ts new file mode 100644 index 00000000..69bc8bfa --- /dev/null +++ b/packages/node/src/cli/storageS3.ts @@ -0,0 +1,212 @@ +import type { S3Client } from '@aws-sdk/client-s3' + +import type { ResolvedCliConfig } from './helpers.ts' +import type { IOutputCtl } from './OutputCtl.ts' + +import { z } from 'zod' + +import { + buildMissingCredentialsMessage, + noticeCliCredentialSource, + resolveCliConfig, +} from './helpers.ts' +import { assertStorageWorkspace } from './storageReceipts.ts' + +interface StorageObject { + path: string + size: number + etag?: string +} + +/** Reads only the public HTTP status, never upstream response bodies or signed request details. */ +export const storageS3ErrorSchema = z.object({ + $metadata: z.object({ httpStatusCode: z.number().optional() }), +}) + +/** Timeout/deadline failures need network advice; other network errors retain existing handling. */ +export const storageS3ConnectionErrorSchema = z.union([ + z.object({ name: z.enum(['TimeoutError', 'AbortError']) }), + z.object({ code: z.enum(['ETIMEDOUT', 'ECONNRESET']) }), +]) + +/** S3's public status cannot tell an endpoint's disabled read API from a permission denial. */ +export function storageS3ReadAdvice(endpoint: string, status?: number): string { + return `${status === 403 ? `The Storage S3 read API at ${endpoint} is not enabled or access is denied; HTTP 403 cannot distinguish the two.` : `Check that the Storage S3 read API at ${endpoint} is enabled.`} Check the endpoint, workspace and Auth Key read or dam:write scope.` +} + +/** Keeps workspace discovery, signing credentials and the trusted endpoint together for S3 reads. */ +export async function withStorageS3( + options: { + endpoint?: string + workspace?: string + projectWorkspace?: string + signal?: AbortSignal + }, + operation: (client: S3Client, workspace: string, endpoint: string) => Promise, + failure: string, + output?: Pick, + config: ResolvedCliConfig = resolveCliConfig(), +): Promise { + if (config.credentials === undefined) + throw new Error(config.loadError ?? buildMissingCredentialsMessage()) + noticeCliCredentialSource(config, output, 'credentials') + const endpoint = new URL( + options.endpoint ?? config.credentialsEndpoint ?? 'https://api2.transloadit.com', + ) + if ( + !['http:', 'https:'].includes(endpoint.protocol) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + (endpoint.pathname !== '/' && endpoint.pathname !== '/storage') + ) { + throw new Error( + 'Storage endpoint must be an HTTP(S) API origin without credentials, query or fragment', + ) + } + endpoint.pathname = '/storage' + // Keep the S3 client out of ordinary CLI startup and image-rendering bundles. + const { ListBucketsCommand, S3Client } = await import('@aws-sdk/client-s3') + const client = new S3Client({ + credentials: { + accessKeyId: config.credentials.authKey, + secretAccessKey: config.credentials.authSecret, + }, + endpoint: endpoint.href, + forcePathStyle: true, + maxAttempts: 2, + region: 'us-east-1', + requestHandler: { + connectionTimeout: 10_000, + requestTimeout: 30_000, + throwOnRequestTimeout: true, + }, + }) + try { + // Each API2 Auth Key belongs to one workspace; empty discovery is not an unverified override. + // Smithy's request timer stops at response headers; the signal also bounds body reads/retries. + const buckets = + options.workspace === undefined || options.projectWorkspace !== undefined + ? (( + await client.send(new ListBucketsCommand({}), { + abortSignal: AbortSignal.any([ + AbortSignal.timeout(60_000), + ...(options.signal === undefined ? [] : [options.signal]), + ]), + }) + ).Buckets ?? []) + : undefined + const workspace = + buckets === undefined + ? options.workspace + : buckets.length === 1 + ? buckets[0]?.Name + : undefined + if (!workspace) + throw new Error( + 'Expected one workspace from Storage discovery; verify the endpoint and Auth Key.', + ) + assertStorageWorkspace(workspace, options.projectWorkspace, options.workspace) + options.signal?.throwIfAborted() + return await operation(client, workspace, endpoint.href) + } catch (error) { + options.signal?.throwIfAborted() + if (storageS3ConnectionErrorSchema.safeParse(error).success) + throw new Error( + `${failure} timed out or lost its connection. Check the Storage endpoint and retry.`, + { cause: error }, + ) + const remote = storageS3ErrorSchema.safeParse(error) + if (remote.success) { + throw new Error( + `${failure} failed${remote.data.$metadata.httpStatusCode === undefined ? '' : ` (HTTP ${remote.data.$metadata.httpStatusCode})`}. ${storageS3ReadAdvice(endpoint.href, remote.data.$metadata.httpStatusCode)}`, + { cause: error }, + ) + } + throw error + } finally { + client.destroy() + } +} + +/** Verify writes against the selected key, not an unverified workspace label in shell dotenv. */ +export async function resolveStorageWorkspace( + options: { endpoint?: string; workspace?: string }, + config: ResolvedCliConfig, + projectWorkspace?: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + if (config.auth === undefined || !('authKey' in config.auth)) + throw new Error( + 'Storage project binding requires an Auth Key. Unset TRANSLOADIT_AUTH_TOKEN to use key credentials; run transloadit auth login if needed.', + ) + const sameEndpoint = + options.endpoint === undefined || + new URL(options.endpoint).origin === + new URL(config.endpoint ?? 'https://api2.transloadit.com').origin + const workspace = + config.authWorkspaceVerified && sameEndpoint && config.authWorkspace !== undefined + ? storageWorkspaceSchema.parse(config.authWorkspace) + : await withStorageS3( + { endpoint: options.endpoint, signal }, + async (_client, actual) => storageWorkspaceSchema.parse(actual), + 'Workspace verification', + undefined, + { + credentials: config.auth, + credentialsEndpoint: config.endpoint, + credentialsSource: config.authSource, + }, + ) + assertStorageWorkspace(workspace, projectWorkspace, options.workspace) + signal?.throwIfAborted() + return workspace +} + +const storageWorkspaceSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/) + +/** Completes every listing page or fails; callers must never persist a silently partial catalog. */ +export async function listStorageObjects( + client: S3Client, + workspace: string, + prefix: string, + signal?: AbortSignal, +): Promise { + const { ListObjectsV2Command } = await import('@aws-sdk/client-s3') + const objects: StorageObject[] = [] + const cursors = new Set() + let cursor: string | undefined + do { + const page = await client.send( + new ListObjectsV2Command({ Bucket: workspace, Prefix: prefix, ContinuationToken: cursor }), + { + abortSignal: AbortSignal.any([ + AbortSignal.timeout(60_000), + ...(signal === undefined ? [] : [signal]), + ]), + }, + ) + for (const object of page.Contents ?? []) { + if ( + object.Key === undefined || + object.Size === undefined || + !Number.isSafeInteger(object.Size) || + object.Size < 0 + ) + throw new Error('Storage returned an incomplete object listing') + objects.push({ + path: object.Key, + size: object.Size, + ...(object.ETag === undefined ? {} : { etag: object.ETag }), + }) + } + if (!page.IsTruncated) break + cursor = page.NextContinuationToken + if (!cursor || cursors.has(cursor)) + throw new Error('Storage omitted or repeated its listing cursor; results would be incomplete') + cursors.add(cursor) + } while (cursor !== undefined) + return objects +} diff --git a/packages/node/src/cli/storageSnippets.ts b/packages/node/src/cli/storageSnippets.ts new file mode 100644 index 00000000..9bfc239d --- /dev/null +++ b/packages/node/src/cli/storageSnippets.ts @@ -0,0 +1,95 @@ +import { statSync } from 'node:fs' +import { readFile } from 'node:fs/promises' + +import { quoteCliArgument } from './helpers.ts' + +/** Locate ordinary Next.js app directories without assuming the consumer's source layout. */ +export function nextAppRoot(): '' | 'src/' | undefined { + if (statSync('app', { throwIfNoEntry: false })?.isDirectory()) return '' + if (statSync('src/app', { throwIfNoEntry: false })?.isDirectory()) return 'src/' + return undefined +} + +/** Advice only: never execute or rewrite a consumer's Next config after a successful upload. */ +export async function storageImageConfigAdvice(): Promise { + for (const file of ['next.config.ts', 'next.config.mjs', 'next.config.js']) { + // An unavailable optional hint must not turn a completed Storage write into a CLI failure. + const source = await readFile(file, 'utf8').catch(() => undefined) + if (source === undefined) continue + if (source.includes('withTransloaditImages')) return '' + const commonJsAdvice = file.endsWith('.js') + ? '\nFor CommonJS, rename next.config.js to next.config.mjs and convert require/module.exports to import/export before using this ESM example.' + : '' + return `\n${file} is not wrapped yet. Keep your existing config in nextConfig and wrap its export:${commonJsAdvice}\nimport { withTransloaditImages } from '@transloadit/img/next/config'\nexport default withTransloaditImages(nextConfig)` + } + return '' +} + +/** Private redirect delivery needs an application authorizer and key, not the CLI login key. */ +export function storageImagePrivateAdvice(path: string, receipts?: string): string { + const prefix = path.slice(0, path.lastIndexOf('/') + 1) + if (prefix === '') + return '\nThis object is private. Configure per-object authorization explicitly, or store it under a directory to use image init --private. Set TRANSLOADIT_SMART_CDN_KEY/SECRET for rendering.' + const catalogOption = receipts === undefined ? '' : ` --receipts=${quoteCliArgument(receipts)}` + // Stop option parsing explicitly: Storage paths are data, including names starting with a dash. + return `\nThis directory is private. Rendering needs transloadit.authorize.ts and ${nextAppRoot() ?? ''}app/api/storage-images/route.ts (npx transloadit image init --private${catalogOption} -- ${quoteCliArgument(prefix)}) and TRANSLOADIT_SMART_CDN_KEY/SECRET. Restart next dev after adding them.` +} + +function relativeImport(path: string): string { + return path.startsWith('./') || path.startsWith('../') ? path : `./${path}` +} + +function sourceString(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029')}'` +} + +/** An empty-safe scaffold showing the first receipt in the initialized directory. */ +export function storageImagePage( + receiptsImport: string, + prefix: string, + receipts?: string, +): string { + const catalogOption = receipts === undefined ? '' : ` --receipts=${quoteCliArgument(receipts)}` + const command = `npx transloadit storage store${catalogOption}${prefix.startsWith('-') ? ' --' : ''} ./hero.jpg ${quoteCliArgument(`${prefix}hero.jpg`)}` + return [ + "import { StorageImage } from '@transloadit/img/next'", + `import catalog from ${sourceString(relativeImport(receiptsImport))}`, + '', + 'export default function Page() {', + ` const path = Object.keys(catalog.images).find((path) => path.startsWith(${sourceString(prefix)}))`, + ' if (path === undefined)', + ' return (', + '

', + ` Run {${sourceString(command)}} to add your`, + ' first image.', + '

', + ' )', + ' // Object.keys only returns own catalog keys, including when the catalog is still empty.', + ' const src = path as keyof typeof catalog.images', + ' // Replace the filename-derived alt with a description, or an empty string if decorative.', + ' const alt = path', + " .slice(path.lastIndexOf('/') + 1)", + " .replace(/\\.[^.]+$/, '')", + " .replaceAll(/[-_]+/g, ' ')", + ' return (', + ' ', + ' This image could not be loaded. Check the Storage path and delivery configuration.', + '

', + ' }', + ' />', + ' )', + '}', + '', + ].join('\n') +} + +/** Rendering-only variable names; values belong in the application's secret configuration. */ +export function storageImageEnvBlock(publicOnly: boolean): string { + return publicOnly ? '' : 'TRANSLOADIT_KEY=\nTRANSLOADIT_SECRET=\n' +} diff --git a/packages/node/src/storageImage.ts b/packages/node/src/storageImage.ts new file mode 100644 index 00000000..f3daa012 --- /dev/null +++ b/packages/node/src/storageImage.ts @@ -0,0 +1,295 @@ +import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts' +import type { CreateAssemblyOptions, Transloadit } from './Transloadit.ts' + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' + +import debug from 'debug' +import { z } from 'zod' + +import { ApiError } from './ApiError.ts' +import InconsistentResponseError from './InconsistentResponseError.ts' + +/** + * Verified Storage metadata that can be saved and passed directly to an image renderer. + * Width and height reflect EXIF auto-orientation, matching Storage preview delivery. + */ +export interface StoredImageReceipt { + readonly asset_id: string + /** Present as true only when the locally decoded original has an alpha channel. */ + readonly hasAlpha?: boolean + readonly height: number + readonly md5hash: string + readonly path: string + readonly size: number + /** Optional base64 ThumbHash of the original pixels, for an inline blur placeholder. */ + readonly thumbhash?: string + readonly width: number +} + +/** One explicit destination, with the existing Assembly upload and polling controls. */ +export interface StoreImageOptions + extends Pick< + CreateAssemblyOptions, + 'chunkSize' | 'onAssemblyProgress' | 'onUploadProgress' | 'signal' | 'timeout' + > { + /** Complete relative Storage filename. Directories and interpolation expressions are rejected. */ + path: string + /** Explicit opt-in replacement of an existing path; defaults to false. */ + overwrite?: boolean + /** Observe verified metadata. Not awaited; sync and async observer errors cannot undo a write. */ + onReceipt?: ( + receipt: StoredImageReceipt, + input: StoredImageExpectation, + assemblyId: string | undefined, + ) => void | Promise +} + +/** Trusted upload facts used to correlate a stored result with an application-owned upload. */ +export interface StoredImageExpectation { + path: string + size: number + md5hash: string +} + +/** Recovers one original's receipt without re-uploading or trusting notification payloads. */ +export interface GetStoredImageReceiptOptions { + assemblyId: string + expected: StoredImageExpectation +} + +const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const receiptRequestSchema = z.object({ + assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/), + expected: z.object({ + path: z.string(), + size: positiveIntegerSchema, + md5hash: z.string().regex(/^[a-f0-9]{32}$/), + }), +}) +// API2 exposes EXIFTool's orientation labels; numeric EXIF tags use the same axis swap. +const dimensionSwappingOrientations = new Set([ + 5, + 6, + 7, + 8, + 'Mirror horizontal and rotate 270 CW', + 'Rotate 90 CW', + 'Mirror horizontal and rotate 90 CW', + 'Rotate 270 CW', +]) +const completedImageSchema = z.object({ + ok: z.literal('ASSEMBLY_COMPLETED'), + results: z.object({ + ':original': z.tuple([ + z.object({ + asset_id: z + .string() + .min(1) + .refine((value) => value.trim() === value), + md5hash: z.string().regex(/^[a-f0-9]{32}$/), + meta: z.object({ + height: positiveIntegerSchema, + orientation: z.union([z.string(), z.number()]).nullable().optional(), + width: positiveIntegerSchema, + }), + path: z.string(), + size: positiveIntegerSchema, + }), + ]), + }), +}) + +function validateDestination(path: string): void { + if ( + typeof path !== 'string' || + path.trim() !== path || + Buffer.byteLength(path) > 1024 || + path.normalize('NFC') !== path || + /[\p{Cc}\p{Cs}\\|]/u.test(path) || + path.includes('${') || + path.split('/').some((segment) => segment.trim() === '' || segment === '.' || segment === '..') + ) { + throw new TypeError('Storage path must be a complete relative filename without interpolation') + } +} + +async function imagePlaceholderMetadata( + bytes: Buffer, +): Promise | undefined> { + try { + const [{ default: sharp }, { rgbaToThumbHash }] = await Promise.all([ + import('sharp'), + import('thumbhash'), + ]) + const image = sharp(bytes, { limitInputPixels: 40_000_000 }) + // Read original-channel metadata, not the alpha channel added for ThumbHash's RGBA input. + const { hasAlpha } = await image.metadata() + const { data, info } = await image + .autoOrient() + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .ensureAlpha() + .raw() + .timeout({ seconds: 2 }) + .toBuffer({ resolveWithObject: true }) + return { + thumbhash: Buffer.from(rgbaToThumbHash(info.width, info.height, data)).toString('base64'), + ...(hasAlpha ? { hasAlpha: true } : {}), + } + } catch { + // A locally unsupported/oversized image can still be stored and decoded by the origin. + debug('transloadit:warn')('Omitted optional ThumbHash: local image decoding was unavailable') + return undefined + } +} + +/** Stores one original without overwriting; receipt validation occurs after the Storage write. */ +export async function storeImage( + client: Transloadit, + filePath: string, + options: StoreImageOptions, +): Promise { + const { + path, + chunkSize, + onAssemblyProgress, + onUploadProgress, + onReceipt, + overwrite, + signal, + timeout, + } = options + validateDestination(path) + if (overwrite !== undefined && typeof overwrite !== 'boolean') + throw new TypeError('overwrite must be a boolean') + signal?.throwIfAborted() + const checksum = createHash('md5') + // Reuse the checksum read, bounded independently of file size; uploads still stream from disk. + let thumbnailChunks: Buffer[] | undefined = [] + let size = 0 + for await (const chunk of createReadStream(filePath, { signal })) { + checksum.update(chunk) + size += chunk.length + if (size > 32 * 1024 * 1024) thumbnailChunks = undefined + else thumbnailChunks?.push(chunk) + } + if (size === 0) throw new Error('Cannot store an empty image') + const md5hash = checksum.digest('hex') + signal?.throwIfAborted() + const placeholder = + thumbnailChunks === undefined + ? undefined + : await imagePlaceholderMetadata(Buffer.concat(thumbnailChunks)) + thumbnailChunks = undefined + signal?.throwIfAborted() + const assembly = await client.createAssembly({ + chunkSize, + files: { image: filePath }, + onAssemblyProgress, + onUploadProgress, + params: { + steps: { + stored: { + robot: '/transloadit/store', + use: ':original', + path, + conflict_strategy: overwrite === true ? 'overwrite' : 'error', + }, + }, + }, + signal, + timeout, + waitForCompletion: true, + }) + const input = { path, size, md5hash } + // API2 can watermark uploads before Robots run; this exact write's result describes stored bytes. + const receipt = validateReceipt(assembly, input, true) + // A watermark or other origin-side rewrite makes the local pixels the wrong placeholder. + const withPlaceholder = + placeholder === undefined || receipt.md5hash !== md5hash || receipt.size !== size + ? receipt + : { ...receipt, ...placeholder } + const observerFailed = (): void => { + debug('transloadit:warn')('Ignored onReceipt observer failure after a completed Storage write') + } + try { + // Neither a stalled observer nor its rejection may hide a successfully stored receipt. + void Promise.resolve(onReceipt?.({ ...withPlaceholder }, input, assembly.assembly_id)).catch( + observerFailed, + ) + } catch { + observerFailed() + } + return withPlaceholder +} + +/** Fetches authoritative Assembly status and applies the same validation as a local image store. */ +export async function getStoredImageReceipt( + client: Transloadit, + options: GetStoredImageReceiptOptions, +): Promise { + const { assemblyId, expected } = receiptRequestSchema.parse(options) + validateDestination(expected.path) + const assembly = await client.getAssembly(assemblyId) + if (assembly.assembly_id !== assemblyId) { + throw new InconsistentResponseError('The response did not match the requested Assembly', { + cause: { assemblyId }, + }) + } + return validateReceipt(assembly, expected) +} + +function validateReceipt( + assembly: AssemblyStatus, + expected: StoredImageExpectation, + acceptTransformed = false, +): StoredImageReceipt { + const { path, size, md5hash } = expected + if (typeof assembly.error === 'string') throw new ApiError({ body: assembly }) + if (assembly.ok === 'ASSEMBLY_CANCELED') { + throw new InconsistentResponseError('The Storage Assembly ended with ASSEMBLY_CANCELED', { + cause: { assemblyId: assembly.assembly_id }, + }) + } + if ( + assembly.ok === 'ASSEMBLY_UPLOADING' || + assembly.ok === 'ASSEMBLY_EXECUTING' || + assembly.ok === 'ASSEMBLY_REPLAYING' + ) { + throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`, { + cause: { assemblyId: assembly.assembly_id }, + }) + } + const parsed = completedImageSchema.safeParse(assembly) + const result = parsed.success ? parsed.data.results[':original'][0] : undefined + const originals = assembly.results?.[':original'] + const receiptCheck = { + originalCount: Array.isArray(originals) ? originals.length : 0, + metadataValid: parsed.success, + pathMatches: result === undefined ? undefined : result.path === path, + sizeMatches: result === undefined ? undefined : result.size === size, + md5Matches: result === undefined ? undefined : result.md5hash === md5hash, + } + if ( + result === undefined || + result.path !== path || + (!acceptTransformed && (!receiptCheck.sizeMatches || !receiptCheck.md5Matches)) + ) { + throw new InconsistentResponseError( + 'The Assembly did not return a matching Storage image receipt', + { + cause: { assemblyId: assembly.assembly_id, receiptCheck }, + }, + ) + } + const { height, orientation, width } = result.meta + const swapDimensions = orientation != null && dimensionSwappingOrientations.has(orientation) + return { + asset_id: result.asset_id, + height: swapDimensions ? width : height, + md5hash: result.md5hash, + path: result.path, + size: result.size, + width: swapDimensions ? height : width, + } +} diff --git a/packages/node/src/storagePublicPrefixes.ts b/packages/node/src/storagePublicPrefixes.ts new file mode 100644 index 00000000..7920eb20 --- /dev/null +++ b/packages/node/src/storagePublicPrefixes.ts @@ -0,0 +1,39 @@ +import { validateStoragePathPrefix } from '@transloadit/utils' +import { z } from 'zod' + +/** Normalize a public directory, never implicit workspace-wide access. */ +export function normalizeStoragePublicPrefix(prefix: string): string { + if (typeof prefix !== 'string' || prefix === '' || prefix === '/') + throw new TypeError('A public prefix must name a non-root directory') + const normalized = prefix.endsWith('/') ? prefix : `${prefix}/` + validateStoragePathPrefix(normalized, 0, 'public prefix') + if (new TextEncoder().encode(normalized).byteLength > 512) + throw new TypeError('A public prefix must be at most 512 UTF-8 bytes') + return normalized +} + +const prefixSchema = z.string().refine((prefix) => { + try { + return normalizeStoragePublicPrefix(prefix) === prefix + } catch { + return false + } +}, 'Expected a normalized public directory') +const publicPrefixSchema = z.object({ prefix: prefixSchema, created_at: z.string() }) +export const storagePublicPrefixesSchema = z.object({ + ok: z.literal('STORAGE_PUBLIC_PREFIXES_LISTED'), + public_prefixes: z.array(publicPrefixSchema), +}) +export const storagePublicPrefixDeclaredSchema = publicPrefixSchema.extend({ + ok: z.literal('STORAGE_PUBLIC_PREFIX_DECLARED'), + created: z.boolean(), +}) +export const storagePublicPrefixRevokedSchema = z.object({ + ok: z.literal('STORAGE_PUBLIC_PREFIX_REVOKED'), + prefix: prefixSchema, + deleted: z.boolean(), +}) + +export type StoragePublicPrefixes = z.infer +export type StoragePublicPrefixDeclared = z.infer +export type StoragePublicPrefixRevoked = z.infer diff --git a/packages/node/test/unit/assembly-status-helpers.test.ts b/packages/node/test/unit/assembly-status-helpers.test.ts index cc6d65a5..3ade3170 100644 --- a/packages/node/test/unit/assembly-status-helpers.test.ts +++ b/packages/node/test/unit/assembly-status-helpers.test.ts @@ -1,14 +1,38 @@ import type { AssemblyStatus } from '../../src/alphalib/types/assemblyStatus.ts' -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { + assemblyStatusResultSchema, + assemblyStatusSchema, isAssemblySysError, isAssemblyTerminal, isAssemblyTerminalError, } from '../../src/alphalib/types/assemblyStatus.ts' describe('assembly status helpers', () => { + it.each([ + 'TRANSLOADIT_IMPORT_ACCESS_DENIED', + 'TRANSLOADIT_IMPORT_FAILURE', + 'TRANSLOADIT_IMPORT_NOT_FOUND', + 'TRANSLOADIT_IMPORT_VALIDATION', + 'TRANSLOADIT_STORE_CONFLICT', + 'TRANSLOADIT_STORE_FAILURE', + 'TRANSLOADIT_STORE_UNAVAILABLE', + 'TRANSLOADIT_STORE_VALIDATION', + ])('validates %s as a terminal Storage error', (error) => { + const status = assemblyStatusSchema.parse({ error, assembly_id: 'storage-error' }) + expect(status.error).toBe(error) + expect(isAssemblyTerminalError(status)).toBe(true) + expect(isAssemblyTerminal(status)).toBe(true) + }) + + it('types and validates Storage asset IDs instead of passing through arbitrary values', () => { + const result = assemblyStatusResultSchema.parse({ asset_id: 'JN6OawlqFmL419U23jUKcg' }) + expect(result.asset_id).toBe('JN6OawlqFmL419U23jUKcg') + expectTypeOf(result.asset_id).toEqualTypeOf() + expect(assemblyStatusResultSchema.safeParse({ asset_id: 123 }).success).toBe(false) + }) it('treats system error shapes as terminal errors', () => { const sysError = { errno: -2, diff --git a/packages/node/test/unit/cli/credentials-resolution.test.ts b/packages/node/test/unit/cli/credentials-resolution.test.ts index a590a282..d34e770d 100644 --- a/packages/node/test/unit/cli/credentials-resolution.test.ts +++ b/packages/node/test/unit/cli/credentials-resolution.test.ts @@ -5,6 +5,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { runSig } from '../../../src/cli/commands/auth.ts' +import { requireCliCredentials, resolveCliConfig } from '../../../src/cli/helpers.ts' import OutputCtl from '../../../src/cli/OutputCtl.ts' import { main } from '../../../src/cli.ts' import { Transloadit } from '../../../src/Transloadit.ts' @@ -59,6 +60,40 @@ function clearAmbientTransloaditEnv(): void { } describe('cli credential resolution', () => { + it.each([ + 'saved login', + 'project .env', + ])('keeps a shell token when %s has an invalid signing algorithm', async (source) => { + const fixture = createCliFixture() + writeFileSync( + source === 'saved login' ? fixture.credentialsFilePath : path.join(fixture.cwd, '.env'), + 'TRANSLOADIT_KEY=invalid-key\nTRANSLOADIT_SECRET=invalid-secret\nTRANSLOADIT_SIGNATURE_ALGORITHM=unsupported\n', + ) + clearAmbientTransloaditEnv() + vi.stubEnv('TRANSLOADIT_SIGNATURE_ALGORITHM', '') + vi.stubEnv('TRANSLOADIT_AUTH_TOKEN', 'valid-shell-token') + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', fixture.credentialsFilePath) + process.chdir(fixture.cwd) + const list = vi + .spyOn(Transloadit.prototype, 'listTemplates') + .mockResolvedValue({ items: [], count: 0 }) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + await main(['templates', 'list']) + expect(list).toHaveBeenCalledOnce() + expect(process.exitCode).toBeUndefined() + expect(resolveCliConfig().auth).toEqual({ authToken: 'valid-shell-token' }) + expect(requireCliCredentials()).toEqual({ + ok: false, + error: 'Unsupported TRANSLOADIT_SIGNATURE_ALGORITHM in CLI credentials', + }) + } finally { + fixture.cleanup() + } + }) + it('uses ~/.transloadit/credentials when shell env and .env are absent', async () => { const fixture = createCliFixture() writeFileSync( diff --git a/packages/node/test/unit/cli/device-login.test.ts b/packages/node/test/unit/cli/device-login.test.ts new file mode 100644 index 00000000..f72e929d --- /dev/null +++ b/packages/node/test/unit/cli/device-login.test.ts @@ -0,0 +1,586 @@ +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { signParamsSync } from '@transloadit/utils/node' +import { execa } from 'execa' +import nock from 'nock' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { resolveCliConfig } from '../../../src/cli/helpers.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' +import { Transloadit } from '../../../src/Transloadit.ts' + +const { waits } = vi.hoisted((): { waits: number[] } => ({ waits: [] })) +vi.mock('execa', () => ({ + execa: vi.fn(() => Object.assign(Promise.resolve(undefined), { unref: vi.fn() })), +})) +vi.mock('node:timers/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + setTimeout: vi.fn((ms: number, value: undefined, options: { signal?: AbortSignal }) => { + waits.push(ms) + return original.setTimeout(1, value, options) + }), + } +}) + +const origin = 'http://127.0.0.1:3020' +const created = { + ok: 'CLI_DEVICE_AUTHORIZATION_CREATED', + device_code: 'fake-device-secret', + user_code: 'BCDF-GHJK', + verification_url: 'https://console.example/c/cli-auth?code=BCDF-GHJK', + expires_in: 900, + interval: 5, +} +const authorized = { + ok: 'CLI_DEVICE_AUTHORIZED', + workspace: 'my-app', + auth_key: 'combined-key', + auth_secret: 'never-print-this-secret', + is_allowed_for_smartcdn: true, + scope: 'dam:write', + signature_algo: 'sha256', +} +const originalCwd = process.cwd() +const stdoutListeners = process.stdout.listeners('error') +const stderrListeners = process.stderr.listeners('error') +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'device-login-')) + process.chdir(directory) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + 'TRANSLOADIT_WORKSPACE', + 'TRANSLOADIT_ENDPOINT', + ]) + vi.stubEnv(name, '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'warn').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + vi.mocked(execa).mockClear() + waits.length = 0 + nock.disableNetConnect() + vi.spyOn(Transloadit.prototype, 'listPublicStoragePrefixes').mockResolvedValue({ + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [], + }) +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllEnvs() + nock.cleanAll() + nock.enableNetConnect() + for (const listener of process.stdout.listeners('error')) + if (!stdoutListeners.includes(listener)) process.stdout.off('error', listener) + for (const listener of process.stderr.listeners('error')) + if (!stderrListeners.includes(listener)) process.stderr.off('error', listener) + await rm(directory, { recursive: true, force: true }) +}) + +function login(args: string[] = []): Promise { + return main(['auth', 'login', '--endpoint', origin, ...args]) +} + +test('a browser denial stops polling immediately and saves no credentials', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(403, { error: 'CLI_DEVICE_AUTHORIZATION_DENIED', message: 'untrusted-secret' }) + await login(['--no-browser']) + expect(api.isDone()).toBe(true) + expect(waits).toEqual([5000]) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith('The login was denied in the browser.') + expect(await readdir(directory)).toEqual([]) +}) + +test('Windows starts the approval browser with an empty title and a safely passed URL', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + createDevice().post('/cli/device_authorizations/token').reply(200, authorized) + await login() + expect(process.exitCode).toBeUndefined() + expect(execa).toHaveBeenCalledWith( + 'cmd', + ['/d', '/v:off', '/c', 'start', '""', '"%TRANSLOADIT_BROWSER_URL%"'], + expect.objectContaining({ + shell: false, + windowsVerbatimArguments: true, + env: { TRANSLOADIT_BROWSER_URL: created.verification_url }, + }), + ) +}) + +function createDevice(response = created): nock.Scope { + return nock(origin) + .post( + '/cli/device_authorizations', + (body) => body.client === 'transloadit-cli' && typeof body.hostname === 'string', + ) + .reply(200, response) +} + +test('a concurrent login preserves the winner and explains that the approved key was not saved', async () => { + const winner = + 'TRANSLOADIT_KEY=winner-key\nTRANSLOADIT_SECRET=winner-secret\nTRANSLOADIT_WORKSPACE=other-workspace\n' + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, async () => { + await writeFile('credentials', winner) + return authorized + }) + await login(['--no-browser']) + expect(api.isDone()).toBe(true) + expect(process.exitCode).toBe(1) + expect(await readFile('credentials', 'utf8')).toBe(winner) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain('approved Auth Key was not saved') + expect(message).toContain('https://transloadit.com/c/my-app/template-credentials/') + expect(message).toContain('revoke it there') + expect(message).toContain('auth logout would use the saved login') + expect(message).toContain('TRANSLOADIT_CREDENTIALS_FILE') + expect(message).not.toMatch( + /Nothing was changed|winner-key|winner-secret|never-print-this-secret|combined-key/, + ) +}) + +test.each([ + 200, 403, +])('reports a long approval wait on stderr and stops after HTTP %s', async (status) => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date', 'performance'] }) + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(() => { + // A laptop clock correction must not extend the advertised authorization lifetime. + vi.setSystemTime(Date.now() - 3_600_000) + vi.advanceTimersByTime(60_000) + return [200, { ok: 'CLI_DEVICE_AUTHORIZATION_PENDING', expires_in: 840 }] + }) + .post('/cli/device_authorizations/token') + .reply(() => { + vi.advanceTimersByTime(60_000) + return [status, status === 200 ? authorized : { error: 'DENIED' }] + }) + await login(['--no-browser', '--json']) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.notice).toHaveBeenNthCalledWith( + 1, + 'Still waiting for approval, 14 minutes left. Use the verification URL printed above.', + ) + expect(OutputCtl.prototype.notice).toHaveBeenNthCalledWith( + 2, + 'Still waiting for approval, 13 minutes left. Use the verification URL printed above.', + ) + vi.advanceTimersByTime(60_000) + expect(OutputCtl.prototype.notice).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.notice).mock.calls)).not.toMatch( + /BCDF|fake-device|combined-key|never-print/, + ) +}) + +test('device login accepts API2 unrestricted keys with a null signature algorithm', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, { + ...authorized, + signature_algo: null, + }) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + const credentials = resolveCliConfig('login').credentials + expect(credentials?.signatureAlgorithm).toBeUndefined() + if (credentials === undefined) throw new Error('Expected saved unrestricted credentials') + const signed = new Transloadit(credentials).calcSignature({ steps: {} }) + expect(signed.signature).toBe(signParamsSync(signed.params, authorized.auth_secret, 'sha384')) + expect(await readFile('credentials', 'utf8')).not.toContain('SIGNATURE_ALGORITHM') +}) + +test('an opener error warns without emitting a false browser result on JSON stdout', async () => { + createDevice().post('/cli/device_authorizations/token').reply(200, authorized) + const warn = vi.spyOn(OutputCtl.prototype, 'warn').mockImplementation(() => {}) + // @ts-expect-error The fake models promise/unref, not unrelated Execa subprocess fields. + vi.mocked(execa).mockImplementationOnce(() => + Object.assign(Promise.reject(new Error('Local opener exited')), { unref: vi.fn() }), + ) + await login(['--json']) + expect(process.exitCode).toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('The browser opener reported an error')) + expect(OutputCtl.prototype.print).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ browserOpened: false }), + ) +}) + +test('login does not await or kill a successfully launched long-lived browser', async () => { + const api = createDevice().post('/cli/device_authorizations/token').reply(200, authorized) + let finishBrowser: (() => void) | undefined + const browser = Object.assign( + new Promise((resolve) => { + finishBrowser = resolve + }), + { unref: vi.fn() }, + ) + // @ts-expect-error The fake models promise/unref, not unrelated Execa process fields. + vi.mocked(execa).mockImplementationOnce(() => browser) + const pending = login() + try { + await expect.poll(() => api.isDone(), { timeout: 500 }).toBe(true) + } finally { + finishBrowser?.() + await pending + } + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(browser.unref).toHaveBeenCalledOnce() + expect(execa).toHaveBeenCalledWith(expect.any(String), [created.verification_url], { + shell: false, + stdio: 'ignore', + detached: true, + cleanup: false, + }) +}) + +test('status reports saved login identity and logout revokes only that key before removing the file', async () => { + const authKeyId = '12345678901234567890123456789012' + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, { + ...authorized, + auth_key_id: authKeyId, + description: 'Transloadit CLI on canary', + }) + .delete('/auth_keys/self', (body: string) => { + const params = /name="params"\r\n\r\n([^\r\n]+)/.exec(body)?.[1] + expect(params).toBeDefined() + if (params === undefined) return false + expect(JSON.parse(params).auth.key).toBe(authorized.auth_key) + return true + }) + .reply(200, { ok: 'AUTH_KEY_DELETED' }) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + await main(['auth', 'status']) + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Transloadit CLI on canary'), + expect.objectContaining({ workspace: 'my-app' }), + ) + vi.stubEnv('TRANSLOADIT_KEY', 'unrelated-shell-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'unrelated-shell-secret') + vi.stubEnv('TRANSLOADIT_ENDPOINT', 'http://untrusted.invalid') + await main(['auth', 'logout']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + await expect(stat('credentials')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('a refused logout keeps the credential file and never claims remote revocation', async () => { + const authKeyId = '12345678901234567890123456789012' + createDevice() + .post('/cli/device_authorizations/token') + .reply(200, { ...authorized, auth_key_id: authKeyId }) + await login(['--no-browser']) + const before = await readFile('credentials', 'utf8') + const api = nock(origin) + .delete('/auth_keys/self') + .reply(403, { error: 'AUTH_KEY_NOT_DELETED', message: 'unsafe never-print-this-secret' }) + await main(['auth', 'logout']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readFile('credentials', 'utf8')).toBe(before) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('not revoked')) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'never-print-this-secret', + ) +}) + +test('browser logout does not require key-id metadata', async () => { + createDevice().post('/cli/device_authorizations/token').reply(200, authorized) + await login(['--no-browser']) + const api = nock(origin).delete('/auth_keys/self').reply(200, { ok: 'AUTH_KEY_DELETED' }) + await main(['auth', 'logout']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + await expect(stat('credentials')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('logout rejects --no-revoke without silently revoking a browser-login key', async () => { + createDevice().post('/cli/device_authorizations/token').reply(200, authorized) + await login(['--no-browser']) + const before = await readFile('credentials', 'utf8') + const api = nock(origin).delete('/auth_keys/self').reply(200, { ok: 'AUTH_KEY_DELETED' }) + await main(['auth', 'logout', '--no-revoke']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(false) + expect(await readFile('credentials', 'utf8')).toBe(before) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('--no-revoke')) +}) + +test('login preflights Storage with the issued key and algorithm, without publishing anything', async () => { + vi.mocked(Transloadit.prototype.listPublicStoragePrefixes).mockRestore() + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, authorized) + .get('/storage/public_prefixes') + .query((query) => { + if (typeof query.params !== 'string') return false + expect(JSON.parse(query.params).auth.key).toBe(authorized.auth_key) + expect(query.signature).toBe(signParamsSync(query.params, authorized.auth_secret, 'sha256')) + return true + }) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Storage policy access verified'), + expect.objectContaining({ storagePolicyAccess: true }), + ) +}) + +test.each([ + 403, 503, +])('a denied or unavailable Storage preflight (HTTP %s) saves the login but prints a Console link', async (status) => { + vi.mocked(Transloadit.prototype.listPublicStoragePrefixes).mockRestore() + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, authorized) + .get('/storage/public_prefixes') + .query(true) + .reply(status, { + error: 'DAM_STORAGE_UNAVAILABLE', + message: 'unsafe upstream never-print-this-secret', + }) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect((await stat('credentials')).mode & 0o777).toBe(0o600) + expect(OutputCtl.prototype.warn).toHaveBeenCalledWith( + expect.stringContaining('https://transloadit.com/c/my-app/'), + ) + const output = JSON.stringify([ + ...vi.mocked(OutputCtl.prototype.warn).mock.calls, + ...vi.mocked(OutputCtl.prototype.print).mock.calls, + ]) + expect(output).not.toMatch( + /unsafe upstream|never-print-this-secret|Storage policy access verified/, + ) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ storagePolicyAccess: false }), + ) +}) + +test('device creation and token polling send form-encoded fields, not JSON', async () => { + const api = nock(origin, { + reqheaders: { 'content-type': 'application/x-www-form-urlencoded' }, + }) + .post( + '/cli/device_authorizations', + (body) => body.client === 'transloadit-cli' && typeof body.hostname === 'string', + ) + .reply(200, created) + .post('/cli/device_authorizations/token', { device_code: created.device_code }) + .reply(200, authorized) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(resolveCliConfig().credentialsWorkspace).toBe(authorized.workspace) +}) + +test.each([ + false, + true, +])('device login polls pending then saves the combined credential (no browser: %s)', async (noBrowser) => { + const api = createDevice() + .post('/cli/device_authorizations/token', { device_code: created.device_code }) + .reply(200, { ok: 'CLI_DEVICE_AUTHORIZATION_PENDING', expires_in: 890 }) + .post('/cli/device_authorizations/token', { device_code: created.device_code }) + .reply(200, authorized) + await login(noBrowser ? ['--no-browser'] : []) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(waits).toEqual([5000, 5000]) + expect(resolveCliConfig()).toMatchObject({ + credentials: { authKey: authorized.auth_key, authSecret: authorized.auth_secret }, + credentialsWorkspace: 'my-app', + credentialsEndpoint: origin, + }) + expect((await stat('credentials')).mode & 0o777).toBe(0o600) + const output = JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls) + expect(output).toContain(created.user_code) + expect(output).toContain(created.verification_url) + expect(output).toContain('Logged in to workspace my-app') + expect(output).not.toContain(created.device_code) + expect(output).not.toContain(authorized.auth_secret) + expect(execa).toHaveBeenCalledTimes(noBrowser ? 0 : 1) +}) + +test('expired device authorization never saves credentials', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(404, { error: 'CLI_DEVICE_AUTHORIZATION_NOT_FOUND', message: 'unsafe device secret' }) + await login(['--no-browser']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readdir(directory)).toEqual([]) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/expired.*auth login/), + ) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'unsafe device secret', + ) +}) + +test('the saved browser credential signs the next API request with its required algorithm', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(200, authorized) + .post('/storage/public_prefixes') + .reply((_uri, body) => { + const encoded = String(body) + const params = /name="params"\r\n\r\n([^\r\n]+)/.exec(encoded)?.[1] + const signature = /name="signature"\r\n\r\n([^\r\n]+)/.exec(encoded)?.[1] + const accepted = + params !== undefined && + signature === signParamsSync(params, authorized.auth_secret, 'sha256') + return accepted + ? [ + 200, + { + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix: 'website/', + created_at: '2026-09-13', + created: true, + }, + ] + : [400, { error: 'INVALID_SIGNATURE' }] + }) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + await main(['storage', 'publish', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(resolveCliConfig().credentials).toMatchObject({ signatureAlgorithm: 'sha256' }) + const credentials = resolveCliConfig().credentials + if (credentials === undefined) throw new Error('Expected the saved combined key') + const client = new Transloadit(credentials) + const signed = client.calcSignature({ steps: {} }) + expect(signed.signature).toBe(signParamsSync(signed.params, authorized.auth_secret, 'sha256')) + const override = client.calcSignature({ steps: {} }, 'sha512') + expect(override.signature).toBe(signParamsSync(override.params, authorized.auth_secret, 'sha512')) +}) + +test('rate limiting slows subsequent polls and honors Retry-After', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(429, { error: 'RATE_LIMIT_REACHED' }, { 'Retry-After': '12' }) + .post('/cli/device_authorizations/token') + .reply(200, { ok: 'CLI_DEVICE_AUTHORIZATION_PENDING', expires_in: 850 }) + .post('/cli/device_authorizations/token') + .reply(200, authorized) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(waits).toEqual([5000, 12000, 12000]) +}) + +test('Ctrl-C cancels polling, leaves no credentials and removes its signal handler', async () => { + const listeners = process.listeners('SIGINT') + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(() => { + process.emit('SIGINT') + return [200, { ok: 'CLI_DEVICE_AUTHORIZATION_PENDING', expires_in: 880 }] + }) + await login(['--no-browser']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readdir(directory)).toEqual([]) + expect(process.listeners('SIGINT')).toEqual(listeners) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringMatching(/canceled/)) +}) + +test('slow_down increases the interval even without Retry-After', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(400, { error: 'slow_down' }) + .post('/cli/device_authorizations/token') + .reply(200, authorized) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(waits).toEqual([5000, 10000]) +}) + +test('an excessive Retry-After cannot overflow a Node timer into immediate polling', async () => { + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(429, {}, { 'Retry-After': '4294968' }) + .post('/cli/device_authorizations/token') + .reply(200, authorized) + await login(['--no-browser']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(waits).toEqual([5000, 900000]) +}) + +test('the local deadline ends polling even if the server keeps reporting pending', async () => { + const deadline = new AbortController() + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal) + const api = createDevice() + .post('/cli/device_authorizations/token') + .reply(() => { + deadline.abort() + return [200, { ok: 'CLI_DEVICE_AUTHORIZATION_PENDING', expires_in: 900 }] + }) + await login(['--no-browser']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readdir(directory)).toEqual([]) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringMatching(/expired/)) +}) + +test('existing credentials require --replace before creating a consumable authorization', async () => { + await writeFile('credentials', 'previous\n') + await login(['--no-browser']) + expect(process.exitCode).toBe(1) + expect(await readFile('credentials', 'utf8')).toBe('previous\n') + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('--replace')) + expect(execa).not.toHaveBeenCalled() +}) + +test.each([ + 'file:///tmp/fake', + 'https://user:password@console.example/c/cli-auth', + 'http://console.example/c/cli-auth', +])('refuses unsafe browser target %s without launching or polling', async (verification_url) => { + const api = createDevice({ ...created, verification_url }) + await login() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(execa).not.toHaveBeenCalled() + expect(await readdir(directory)).toEqual([]) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + verification_url, + ) +}) diff --git a/packages/node/test/unit/cli/image-onboarding.test.ts b/packages/node/test/unit/cli/image-onboarding.test.ts new file mode 100644 index 00000000..10c43e2b --- /dev/null +++ b/packages/node/test/unit/cli/image-onboarding.test.ts @@ -0,0 +1,939 @@ +import { + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + utimes, + writeFile, +} from 'node:fs/promises' +import { homedir, tmpdir, userInfo } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest' + +import { + getConfiguredCredentialsFilePath, + readCliInput, + resolveCliConfig, +} from '../../../src/cli/helpers.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' +import { Transloadit } from '../../../src/Transloadit.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { ...original, open: vi.fn(original.open), rm: vi.fn(original.rm) } +}) + +vi.mock('../../../src/cli/helpers.ts', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + readCliInput: vi.fn(original.readCliInput), + resolveCliConfig: vi.fn(original.resolveCliConfig), + } +}) + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal() + return { ...original, homedir: vi.fn(original.homedir) } +}) + +const originalCwd = process.cwd() +const stdoutListeners = process.stdout.listeners('error') +const stderrListeners = process.stderr.listeners('error') +let directory: string + +beforeEach(async () => { + vi.mocked(readCliInput).mockClear() + directory = await mkdtemp(join(tmpdir(), 'img-onboarding-')) + process.chdir(directory) + vi.mocked(homedir).mockReturnValue(join(directory, 'fake-home')) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + ]) + vi.stubEnv(name, '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ items: [], count: 0 }) + vi.spyOn(Transloadit.prototype, 'listPublicStoragePrefixes').mockResolvedValue({ + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [], + }) + vi.mocked(readCliInput).mockResolvedValue({ + content: 'TRANSLOADIT_KEY=write-key\nTRANSLOADIT_SECRET=hidden-secret\n', + isStdin: true, + }) +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const listener of process.stdout.listeners('error')) { + if (!stdoutListeners.includes(listener)) process.stdout.off('error', listener) + } + for (const listener of process.stderr.listeners('error')) { + if (!stderrListeners.includes(listener)) process.stderr.off('error', listener) + } + await rm(directory, { force: true, recursive: true }) +}) + +test('auth login saves owner-only credentials in the existing lookup without leaking secrets', async () => { + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBeUndefined() + expect(Transloadit.prototype.listTemplates).toHaveBeenCalledExactlyOnceWith({ pagesize: 1 }) + expect((await stat('credentials')).mode & 0o777).toBe(0o600) + expect(resolveCliConfig().credentials).toEqual({ + authKey: 'write-key', + authSecret: 'hidden-secret', + }) + expect(await readdir(directory)).toEqual(['credentials']) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).not.toContain( + 'hidden-secret', + ) +}) + +test.each([ + false, + true, +])('imported credentials require explicit revocation consent (%s)', async (revoke) => { + const revocation = vi + .spyOn(Transloadit.prototype, 'revokeOwnAuthKey') + .mockResolvedValue(undefined) + // Input cannot relabel an imported application key as a disposable browser-login key. + vi.mocked(readCliInput).mockResolvedValue({ + content: + 'TRANSLOADIT_KEY=write-key\nTRANSLOADIT_SECRET=hidden-secret\nTRANSLOADIT_LOGIN_METHOD=device\n', + isStdin: true, + }) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBeUndefined() + await main(['auth', 'logout', ...(revoke ? ['--revoke'] : [])]) + expect(process.exitCode).toBeUndefined() + expect(revocation).toHaveBeenCalledTimes(revoke ? 1 : 0) + await expect(stat('credentials')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(OutputCtl.prototype.print).toHaveBeenLastCalledWith(expect.any(String), { + revoked: revoke, + removed: true, + }) +}) + +test('legacy credentials without login provenance are forgotten without revoking a shared key', async () => { + await writeFile('credentials', 'TRANSLOADIT_KEY=legacy-key\nTRANSLOADIT_SECRET=legacy-secret\n') + const revocation = vi + .spyOn(Transloadit.prototype, 'revokeOwnAuthKey') + .mockResolvedValue(undefined) + await main(['auth', 'logout']) + expect(process.exitCode).toBeUndefined() + expect(revocation).not.toHaveBeenCalled() + await expect(stat('credentials')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith(expect.stringContaining('not revoked'), { + revoked: false, + removed: true, + }) +}) + +test.each([ + 'TRANSLOADIT_AUTH_TOKEN=legacy-token\n', + 'TRANSLOADIT_KEY=legacy-key\nTRANSLOADIT_SECRET=legacy-secret\nTRANSLOADIT_SIGNATURE_ALGORITHM=unsupported\n', +])('local-only logout can remove unusable legacy credentials: %j', async (contents) => { + await writeFile('credentials', contents) + const revocation = vi.spyOn(Transloadit.prototype, 'revokeOwnAuthKey') + await main(['auth', 'logout']) + expect(process.exitCode).toBeUndefined() + expect(revocation).not.toHaveBeenCalled() + await expect(stat('credentials')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith(expect.any(String), { + revoked: false, + removed: true, + }) +}) + +test('explicit revocation still requires usable signing credentials and preserves an invalid file', async () => { + const contents = + 'TRANSLOADIT_KEY=legacy-key\nTRANSLOADIT_SECRET=legacy-secret\nTRANSLOADIT_SIGNATURE_ALGORITHM=unsupported\n' + await writeFile('credentials', contents) + const revocation = vi.spyOn(Transloadit.prototype, 'revokeOwnAuthKey') + await main(['auth', 'logout', '--revoke']) + expect(process.exitCode).toBe(1) + expect(revocation).not.toHaveBeenCalled() + expect(await readFile('credentials', 'utf8')).toBe(contents) +}) + +test('auth login rejects failed verification without saving credentials or echoing upstream errors', async () => { + vi.mocked(Transloadit.prototype.listTemplates).mockRejectedValue( + new Error('remote hidden-secret'), + ) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBe(1) + expect(await readdir(directory)).toEqual([]) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain('https://transloadit.com/c//template-credentials/') + expect(message).toContain('verify') + expect(message).toContain('TRANSLOADIT_SIGNATURE_ALGORITHM=sha256') + expect(message).not.toContain('hidden-secret') +}) + +test('auth login help documents the stdin algorithm needed for a combined Smart CDN key', async () => { + await main(['auth', 'login', '--help']) + expect(process.exitCode).toBeUndefined() + const help = vi + .mocked(process.stdout.write) + .mock.calls.map(([chunk]) => String(chunk)) + .join('') + expect(help).toContain('TRANSLOADIT_SIGNATURE_ALGORITHM=sha256') +}) + +test.each([ + ['--help'], + ['-h'], + [], +])('auth help %j lists each command once without treating aliases as ambiguous matches', async (...args) => { + await main(['auth', ...args]) + expect(process.exitCode).toBeUndefined() + const help = vi + .mocked(process.stdout.write) + .mock.calls.map(([chunk]) => String(chunk)) + .join('') + const commands = [ + ...help.matchAll(/transloadit auth (signature|smart-cdn|token|login|logout|status)\b/g), + ].map((match) => match[1]) + expect(commands.toSorted()).toEqual([ + 'login', + 'logout', + 'signature', + 'smart-cdn', + 'status', + 'token', + ]) + expect(help).not.toContain('Multiple commands match') +}) + +test.each([ + ['auth', 'sig'], + ['sig'], + ['auth', 'smart_cdn'], + ['smart_sig'], +])('auth alias %j still exposes command-specific help', async (...args) => { + await main([...args, '--help']) + expect(process.exitCode).toBeUndefined() + const help = vi + .mocked(process.stdout.write) + .mock.calls.map(([chunk]) => String(chunk)) + .join('') + expect(help).toContain('Generate') + expect(help).toContain('Options') + expect(help).not.toContain('Authentication commands') +}) + +test('auth login verifies only against the explicit endpoint and saves that binding', async () => { + await writeFile('.env', 'TRANSLOADIT_ENDPOINT=https://untrusted.invalid\n') + await main(['auth', 'login', '--stdin', '--endpoint', 'http://127.0.0.1:3020']) + expect(process.exitCode).toBeUndefined() + expect(resolveCliConfig().credentialsEndpoint).toBe('http://127.0.0.1:3020') +}) + +test('project dotenv cannot redirect newly entered credentials into the application', async () => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', '') + await writeFile('.env', 'TRANSLOADIT_CREDENTIALS_FILE=public/credentials.txt\n') + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('fake-home/.transloadit/credentials', 'utf8')).toContain('write-key') + await expect(stat('public/credentials.txt')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('project HOME cannot redirect newly entered credentials when the shell has no HOME', async () => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', '') + vi.stubEnv('HOME', undefined) + const originalHome = join(directory, 'fake-home') + // Model os.homedir's POSIX HOME lookup without ever touching a real home directory. + vi.mocked(homedir).mockImplementation(() => process.env.HOME ?? originalHome) + await writeFile('.env', `HOME=${join(directory, 'public')}\n`) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('fake-home/.transloadit/credentials', 'utf8')).toContain('write-key') + await expect(stat('public/.transloadit/credentials')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('project HOME cannot turn a repository credential file into a verified saved login', async () => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', '') + vi.stubEnv('HOME', undefined) + const originalHome = join(directory, 'fake-home') + vi.mocked(homedir).mockImplementation(() => process.env.HOME ?? originalHome) + await mkdir('public/.transloadit', { recursive: true }) + await writeFile('.env', `HOME=${join(directory, 'public')}\n`) + await writeFile( + 'public/.transloadit/credentials', + 'TRANSLOADIT_KEY=repo-key\nTRANSLOADIT_SECRET=repo-secret\nTRANSLOADIT_WORKSPACE=my-app\nTRANSLOADIT_WORKSPACE_VERIFIED=true\n', + ) + await main(['auth', 'status']) + expect(process.exitCode).toBe(1) + expect(resolveCliConfig()).toMatchObject({ + authSource: 'project-selected credentials file', + authWorkspaceVerified: false, + credentialsWorkspaceVerified: false, + }) +}) + +test('the generated empty page and init instruction name the initialized directory', async () => { + await mkdir('app') + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=write-key\nTRANSLOADIT_SECRET=hidden-secret\nTRANSLOADIT_WORKSPACE=my-app\nTRANSLOADIT_WORKSPACE_VERIFIED=true\n', + ) + await main(['image', 'init', 'uploads/', '--private', '--example']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + 'npx transloadit storage store ./hero.jpg uploads/hero.jpg', + ) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Add an image under uploads/ with storage store'), + expect.any(Object), + ) +}) + +test.each([ + '', + ' ', + './public', +])('HOME=%j cannot make the default credential path repository-relative', (home) => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', '') + vi.mocked(homedir).mockReturnValue(home) + // Only resolve the path; never read or write the real account's credentials in this test. + expect(getConfiguredCredentialsFilePath('shell')).toBe( + join(userInfo().homedir, '.transloadit', 'credentials'), + ) +}) + +test.each(['.env', '.env.local'])('auth login never replaces app env file %s', async (file) => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, file)) + await writeFile(file, 'APP_SETTING=preserved\n') + await main(['auth', 'login', '--stdin', '--replace']) + expect(process.exitCode).toBe(1) + expect(await readFile(file, 'utf8')).toBe('APP_SETTING=preserved\n') + expect(readCliInput).not.toHaveBeenCalled() +}) + +test.each([ + 'opaque;secret/*:value', + 'with # punctuation', + 'quote"value', + "quote'value", + 'literal\\nvalue', +])('auth login preserves opaque secret %j through the existing credential lookup', async (secret) => { + vi.mocked(readCliInput).mockResolvedValue({ + content: `TRANSLOADIT_KEY=write-key\nTRANSLOADIT_SECRET='${secret}'\n`, + isStdin: true, + }) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBeUndefined() + expect(resolveCliConfig().credentials?.authSecret).toBe(secret) +}) + +test('auth login does not overwrite existing credentials without explicit replacement', async () => { + await writeFile('credentials', 'previous\n') + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBe(1) + expect(await readFile('credentials', 'utf8')).toBe('previous\n') + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('--replace')) +}) + +test('a concurrent stdin login preserves the winner without suggesting revocation of the supplied key', async () => { + const winner = 'TRANSLOADIT_KEY=winner-key\nTRANSLOADIT_SECRET=winner-secret\n' + vi.mocked(Transloadit.prototype.listTemplates).mockImplementationOnce(async () => { + await writeFile('credentials', winner) + return { items: [], count: 0 } + }) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBe(1) + expect(await readFile('credentials', 'utf8')).toBe(winner) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain('verified Auth Key was not saved') + expect(message).toContain('TRANSLOADIT_CREDENTIALS_FILE') + expect(message).toContain('No new Auth Key was created') + expect(message).not.toMatch(/revoke|winner-key|winner-secret|hidden-secret|write-key/) +}) + +test('auth login identifies the saved file and offers a separate login without reading input or overwriting', async () => { + const contents = + 'TRANSLOADIT_KEY=existing-key\nTRANSLOADIT_SECRET=existing-secret\nTRANSLOADIT_WORKSPACE=existing-workspace\nTRANSLOADIT_AUTH_KEY_DESCRIPTION="Transloadit CLI on old-laptop"\n' + await writeFile('credentials', contents) + await utimes('credentials', new Date('2026-04-14T12:00:00Z'), new Date('2026-04-14T12:00:00Z')) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBe(1) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(join(directory, 'credentials')) + expect(message).toContain('existing-workspace') + expect(message).toContain('Transloadit CLI on old-laptop') + expect(message).toContain('2026-04-14T12:00:00.000Z') + expect(message).toContain('TRANSLOADIT_CREDENTIALS_FILE') + expect(message).toContain('--replace') + expect(message).not.toMatch(/existing-key|existing-secret/) + expect(readCliInput).not.toHaveBeenCalled() + expect(Transloadit.prototype.listTemplates).not.toHaveBeenCalled() + expect(await readFile('credentials', 'utf8')).toBe(contents) +}) + +test('existing-login metadata is optional and cannot inject terminal controls into the error', async () => { + await writeFile( + 'credentials', + 'TRANSLOADIT_SECRET=never-print-me\nTRANSLOADIT_AUTH_KEY_DESCRIPTION="old\u001b[2Jlogin"\n', + ) + await main(['auth', 'login', '--stdin']) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain('Workspace: not recorded') + expect(message).toContain('Description: "old\\u001b[2Jlogin"') + expect(message).toContain('TRANSLOADIT_CREDENTIALS_FILE') + expect(message).not.toContain('\u001b') + expect(message).not.toContain('never-print-me') +}) + +test('auth login rejects malformed input without echoing it or saving a file', async () => { + vi.mocked(readCliInput).mockResolvedValue({ + content: 'TRANSLOADIT_KEY=hidden-secret\n', + isStdin: true, + }) + await main(['auth', 'login', '--stdin']) + expect(process.exitCode).toBe(1) + expect(await readdir(directory)).toEqual([]) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'hidden-secret', + ) +}) + +describe('image init', () => { + beforeEach(() => { + const credentials = { authKey: 'combined-key', authSecret: 'render-secret' } + vi.mocked(resolveCliConfig).mockReturnValue({ + auth: credentials, + credentials, + credentialsWorkspace: 'my-app', + authWorkspace: 'my-app', + authWorkspaceVerified: true, + }) + vi.spyOn(Transloadit.prototype, 'publishStoragePrefix').mockResolvedValue({ + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix: 'website/', + created: true, + created_at: '2026-09-13', + }) + }) + + test('private init scaffolds only the conventional authorizer and package route, not a factory', async () => { + await mkdir('app') + await main(['image', 'init', 'uploads/', '--private']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('transloadit.authorize.ts', 'utf8')).toContain('export const authorize') + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).toMatch( + /Smart CDN.*smart_cdn:sign.*assemblies:write is also accepted/, + ) + expect(await readFile('app/api/storage-images/route.ts', 'utf8')).toBe( + "export { GET, HEAD } from '@transloadit/img/next/route'\n", + ) + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat('app/storage-image-example/page.tsx')).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + test('example scaffolding uses an existing catalog without login or implicit publication', async () => { + await mkdir('app') + const catalog = { workspace: 'my-app', public: ['website/'], images: {} } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + vi.mocked(resolveCliConfig).mockReturnValue({}) + await main(['image', 'init', 'website/', '--example']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '@transloadit/img/next'", + ) + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).toContain( + 'withTransloaditImages', + ) + expect(Transloadit.prototype.publishStoragePrefix).not.toHaveBeenCalled() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) + }) + + test('a custom-catalog example keeps application factories intact and names the plugin option', async () => { + await mkdir('app') + await mkdir('lib') + await writeFile('lib/storageImage.ts', 'application-owned code\n') + await writeFile( + 'photos.json', + JSON.stringify({ workspace: 'my-app', public: ['website/'], images: {} }), + ) + await main(['image', 'init', 'website/', '--example', '--receipts', 'photos.json']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('lib/storageImage.ts', 'utf8')).toBe('application-owned code\n') + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '../../photos.json'", + ) + const output = vi + .mocked(OutputCtl.prototype.print) + .mock.calls.map(([message]) => message) + .join('\n') + expect(output).toContain('withTransloaditImages') + expect(output).toContain('{ catalog: "photos.json" }') + }) + + test.each([ + '--example', + '--public', + '--private', + ])('init %s rejects an external catalog before any scaffold or publication', async (mode) => { + await mkdir('app') + await writeFile('photos.json', JSON.stringify({ workspace: 'my-app', public: [], images: {} })) + const original = await readFile('photos.json', 'utf8') + await mkdir('site/app', { recursive: true }) + const previousDirectory = process.cwd() + process.chdir('site') + try { + await main(['image', 'init', 'website/', mode, '--receipts', '../photos.json']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/outside this Next.js app.*explicit.*createStorageImages/), + ) + expect(Transloadit.prototype.publishStoragePrefix).not.toHaveBeenCalled() + expect(await readdir('.')).toEqual(['app']) + expect(await readdir('app')).toEqual([]) + expect(await readFile('../photos.json', 'utf8')).toBe(original) + } finally { + process.chdir(previousDirectory) + } + }) + + test.each([ + '--example', + '--private', + ])('credential-free %s preserves production delivery despite a saved development login', async (mode) => { + await mkdir('app') + const catalog = { workspace: 'my-app', public: ['website/'], images: {} } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + const saved = resolveCliConfig('login') + vi.mocked(resolveCliConfig).mockReturnValue({ + ...saved, + workspace: 'other-app', + endpoint: 'http://127.0.0.1:3020', + }) + await main(['image', 'init', 'website/', mode]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) + if (mode === '--example') { + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).not.toContain( + '127.0.0.1', + ) + } + }) + + test.each([ + '--example', + '--private', + ])('credential-free %s validates an explicit workspace expectation before writing', async (mode) => { + await mkdir('app') + const catalog = JSON.stringify({ workspace: 'my-app', public: ['website/'], images: {} }) + await writeFile('transloadit.images.json', catalog) + await main(['image', 'init', 'website/', mode, '--workspace', 'other-app']) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(catalog) + expect(await readdir('app')).toEqual([]) + await expect(stat('transloadit.authorize.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('an explicit production endpoint clears catalog transport without a factory override', async () => { + await mkdir('app') + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + public: ['website/'], + images: {}, + delivery: { + baseUrl: 'http://127.0.0.1:3020/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + }), + ) + await main([ + 'image', + 'init', + 'website/', + '--example', + '--endpoint', + 'https://api2.transloadit.com', + ]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).delivery).toBeUndefined() + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).not.toContain('baseUrl:') + }) + + test.each([ + { flags: ['--public'], loginEndpoint: 'http://127.0.0.1:3020', catalogEndpoint: undefined }, + { + flags: ['--private', '--write-env'], + loginEndpoint: 'http://127.0.0.1:3020', + catalogEndpoint: undefined, + }, + { flags: ['--public'], loginEndpoint: undefined, catalogEndpoint: 'http://127.0.0.1:3020' }, + { + flags: ['--private', '--write-env'], + loginEndpoint: undefined, + catalogEndpoint: 'http://127.0.0.1:3020', + }, + ])('refuses a credential/catalog endpoint mismatch before any write: %j', async ({ + flags, + loginEndpoint, + catalogEndpoint, + }) => { + await mkdir('app') + const catalog = JSON.stringify({ + workspace: 'my-app', + public: [], + images: {}, + delivery: + catalogEndpoint === undefined + ? undefined + : { + baseUrl: `${catalogEndpoint}/file/{workspace}`, + urlParams: { cdn: 'required' }, + }, + }) + await writeFile('transloadit.images.json', catalog) + vi.mocked(resolveCliConfig).mockReturnValue({ + ...resolveCliConfig('login'), + endpoint: loginEndpoint, + }) + await main(['image', 'init', 'website/', ...flags]) + expect(process.exitCode).toBe(1) + expect(Transloadit.prototype.publishStoragePrefix).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/endpoint.*catalog.*Nothing was written/), + ) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(catalog) + expect(await readdir(directory)).toEqual(['app', 'transloadit.images.json']) + expect(await readdir('app')).toEqual([]) + }) + + test('an explicit endpoint deliberately moves publication and catalog delivery together', async () => { + await mkdir('app') + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'my-app', public: [], images: {} }), + ) + const endpoint = 'http://127.0.0.1:3020' + vi.mocked(resolveCliConfig).mockReturnValue({ ...resolveCliConfig('login'), endpoint }) + await main(['image', 'init', 'website/', '--public', '--endpoint', endpoint]) + expect(process.exitCode).toBeUndefined() + expect(Transloadit.prototype.publishStoragePrefix).toHaveBeenCalledOnce() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toMatchObject({ + public: ['website/'], + delivery: { baseUrl: `${endpoint}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) + }) + + test('matching catalog and login origins allow private credential scaffolding', async () => { + await mkdir('app') + const endpoint = 'http://127.0.0.1:3020' + const catalog = { + workspace: 'my-app', + public: [], + images: {}, + delivery: { baseUrl: `${endpoint}/file/{workspace}`, urlParams: { cdn: 'required' } }, + } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + vi.mocked(resolveCliConfig).mockReturnValue({ ...resolveCliConfig('login'), endpoint }) + await main(['image', 'init', 'uploads/', '--private', '--write-env']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) + expect((await stat('.env.local')).mode & 0o777).toBe(0o600) + expect(Transloadit.prototype.publishStoragePrefix).not.toHaveBeenCalled() + }) + + test.each([ + '--public', + '--private', + ])('init carries a saved non-production endpoint for %s', async (mode) => { + await mkdir('app') + const saved = resolveCliConfig('login') + vi.mocked(resolveCliConfig).mockReturnValue({ ...saved, endpoint: 'http://127.0.0.1:3020' }) + await main(['image', 'init', 'website/', mode]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).delivery).toEqual({ + baseUrl: 'http://127.0.0.1:3020/file/{workspace}', + urlParams: { cdn: 'required' }, + }) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).toContain( + 'Delivery uses the non-production API', + ) + }) + + test('a rollback failure keeps the original error and public-prefix warning and continues cleanup', async () => { + await mkdir('app') + const original = await vi.importActual('node:fs/promises') + vi.mocked(open).mockImplementation((path, ...options) => { + if (path === 'app/storage-image-example/page.tsx') + return Promise.reject(new Error('Cannot write example page')) + return original.open(path, ...options) + }) + vi.mocked(rm).mockImplementation((path, ...options) => { + if (path === 'transloadit.images.json') + return Promise.reject(new Error('Cannot remove partial catalog')) + return original.rm(path, ...options) + }) + onTestFinished(() => { + vi.mocked(open).mockReset() + vi.mocked(rm).mockReset() + }) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/Cannot write example page.*remains public/), + ) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('transloadit.images.json'), + ) + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).public).toEqual([ + 'website/', + ]) + }) + + test.each([ + 'app', + 'src/app', + ])('image init uses the package component for %s without a generated factory or env', async (app) => { + await mkdir(app, { recursive: true }) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBeUndefined() + const root = app === 'app' ? '' : 'src/' + await expect(stat(`${root}lib/storageImage.ts`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images: {}, + }) + const page = await readFile(`${app}/storage-image-example/page.tsx`, 'utf8') + expect(page).toContain("from '@transloadit/img/next'") + expect(page).toContain('keyof typeof catalog.images') + expect(page).toContain(' { + await mkdir('app') + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect(process.exitCode).toBeUndefined() + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(readCliInput).not.toHaveBeenCalled() + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).not.toContain( + 'render-secret', + ) + }) + + test('private init keeps keys in env and workspace in the catalog', async () => { + await mkdir('app') + await main(['image', 'init', 'accounts/', '--write-env', '--private']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('.env.local', 'utf8')).toBe( + 'TRANSLOADIT_KEY="combined-key"\nTRANSLOADIT_SECRET="render-secret"\n', + ) + }) + + test('private init preserves existing public directories without creating a second factory policy', async () => { + await mkdir('app') + const catalog = { workspace: 'my-app', public: ['website/'], images: {} } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + await main(['image', 'init', 'uploads/', '--private']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('transloadit.authorize.ts', 'utf8')).toContain('authorize') + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) + }) + + test('the generated example selects a receipt under the initialized directory', async () => { + await mkdir('app') + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + public: [], + images: { + 'accounts/avatar.jpg': { path: 'accounts/avatar.jpg', width: 200, height: 200 }, + 'website/hero.jpg': { path: 'website/hero.jpg', width: 800, height: 600 }, + }, + }), + ) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "Object.keys(catalog.images).find((path) => path.startsWith('website/'))", + ) + }) + + test('init normalizes a directory without its trailing slash', async () => { + await mkdir('app') + await main(['image', 'init', 'website', '--public']) + expect(process.exitCode).toBeUndefined() + expect(Transloadit.prototype.publishStoragePrefix).toHaveBeenCalledExactlyOnceWith('website/', { + signal: expect.any(AbortSignal), + }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).public).toEqual([ + 'website/', + ]) + }) + + test('init never overwrites an existing rendering env file, even with --write-env', async () => { + await mkdir('app') + await writeFile('.env.local', 'APP_SETTING=preserved\n') + await main(['image', 'init', 'website/', '--private', '--write-env']) + expect(process.exitCode).toBe(1) + expect(await readFile('.env.local', 'utf8')).toBe('APP_SETTING=preserved\n') + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('generated source files use normal permissions while rendering secrets remain owner-only', async () => { + await mkdir('app') + await main(['image', 'init', 'website/', '--private', '--write-env']) + expect(process.exitCode).toBeUndefined() + expect((await stat('transloadit.authorize.ts')).mode & 0o777).toBe(0o666 & ~process.umask()) + expect((await stat('app/api/storage-images/route.ts')).mode & 0o777).toBe( + 0o666 & ~process.umask(), + ) + expect((await stat('.env.local')).mode & 0o777).toBe(0o600) + }) + + test('public and private scaffold declarations cannot be combined', async () => { + await mkdir('app') + await main(['image', 'init', 'website/', '--public', '--private']) + expect(process.exitCode).toBe(1) + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('private init creates GET and HEAD with a fail-closed authorization placeholder', async () => { + await mkdir('app') + await main(['image', 'init', '--private', 'accounts/']) + expect(process.exitCode).toBeUndefined() + expect(await readFile('transloadit.authorize.ts', 'utf8')).toContain('= () => false') + expect(await readFile('app/api/storage-images/route.ts', 'utf8')).toContain( + "export { GET, HEAD } from '@transloadit/img/next/route'", + ) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).toContain( + 'authorization', + ) + }) + + test('init refuses to replace application code and leaves no partial scaffold', async () => { + await mkdir('app/api/storage-images', { recursive: true }) + await writeFile('app/api/storage-images/route.ts', 'existing\n') + await main(['image', 'init', '--private', 'accounts/']) + expect(process.exitCode).toBe(1) + expect(await readFile('app/api/storage-images/route.ts', 'utf8')).toBe('existing\n') + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test.each([ + '../', + '/website/', + 'a//', + 'a/../', + ' website/', + 'website/ ', + 'website/\u0001/', + 'cafe\u0301/', + `${'a'.repeat(1024)}/`, + '', + ])('init rejects unsafe or implicit root prefix %j before writing', async (prefix) => { + await mkdir('app') + await main(['image', 'init', prefix, '--public']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Provide one safe relative directory prefix ending in /, for example website/', + ) + expect(await readdir(directory)).toEqual(['app']) + }) + + test('init preserves an existing catalog and refuses to overwrite the example page', async () => { + await mkdir('app/storage-image-example', { recursive: true }) + const catalog = + '{"workspace":"my-app","public":[],"images":{"website/hero.jpg":{"path":"website/hero.jpg","width":800,"height":600}}}\n' + await writeFile('transloadit.images.json', catalog) + await writeFile('app/storage-image-example/page.tsx', 'existing\n') + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(catalog) + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toBe('existing\n') + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Move or rename it before rerunning image init'), + ) + await expect(stat('lib/storageImage.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test.each([ + false, + true, + ])('init keeps the selected catalog portable without replacing its data (absolute: %s)', async (absolute) => { + await mkdir('src/app', { recursive: true }) + await mkdir('catalog') + const catalog = + '{"workspace":"my-app","public":[],"images":{"website/hero.jpg":{"path":"website/hero.jpg","width":800,"height":600}}}\n' + await writeFile('catalog/images.json', catalog) + await main([ + 'image', + 'init', + 'website/', + '--public', + '--receipts', + absolute ? join(process.cwd(), 'catalog/images.json') : 'catalog/images.json', + ]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('catalog/images.json', 'utf8'))).toEqual({ + ...JSON.parse(catalog), + public: ['website/'], + }) + expect(await readFile('src/app/storage-image-example/page.tsx', 'utf8')).toContain( + '../../../catalog/images.json', + ) + expect(await readFile('src/app/storage-image-example/page.tsx', 'utf8')).toContain( + '--receipts=catalog/images.json', + ) + expect(await readFile('src/app/storage-image-example/page.tsx', 'utf8')).not.toContain( + process.cwd(), + ) + }) + + test('init rejects the removed --next flag before writing', async () => { + await mkdir('app') + await main(['image', 'init', 'website/', '--next']) + expect(process.exitCode).toBe(1) + expect(await readdir(directory)).toEqual(['app']) + }) +}) diff --git a/packages/node/test/unit/cli/storage-list.test.ts b/packages/node/test/unit/cli/storage-list.test.ts new file mode 100644 index 00000000..ed195171 --- /dev/null +++ b/packages/node/test/unit/cli/storage-list.test.ts @@ -0,0 +1,202 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import nock from 'nock' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' + +const originalCwd = process.cwd() +const stdoutListeners = process.stdout.listeners('error') +const stderrListeners = process.stderr.listeners('error') +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'storage-list-')) + process.chdir(directory) + await writeFile('credentials', 'TRANSLOADIT_KEY=local-key\nTRANSLOADIT_SECRET=local-secret\n') + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + 'TRANSLOADIT_ENDPOINT', + ]) + vi.stubEnv(name, '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + nock.disableNetConnect() +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + nock.cleanAll() + nock.enableNetConnect() + for (const listener of process.stdout.listeners('error')) + if (!stdoutListeners.includes(listener)) process.stdout.off('error', listener) + for (const listener of process.stderr.listeners('error')) + if (!stderrListeners.includes(listener)) process.stderr.off('error', listener) + await rm(directory, { force: true, recursive: true }) +}) + +test('listing help explains that ETags require JSON output', async () => { + await main(['storage', 'ls', '--help']) + expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining('ETags with --json')) +}) + +test.each([ + undefined, + 'http://override.invalid', +])('uses the saved credentials endpoint, with only the explicit %j override taking precedence', async (endpoint) => { + await writeFile( + '.env', + 'TRANSLOADIT_AUTH_TOKEN=project-token\nTRANSLOADIT_ENDPOINT=http://token.invalid\n', + ) + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=local-key\nTRANSLOADIT_SECRET=local-secret\nTRANSLOADIT_ENDPOINT=http://saved.invalid\n', + ) + const intended = nock(endpoint ?? 'http://saved.invalid', { + reqheaders: { + authorization: (value: string) => value.startsWith('AWS4-HMAC-SHA256 Credential=local-key/'), + }, + }) + .get('/storage/my-app/') + .query(true) + .reply(200, 'false') + const unrelated = nock('http://token.invalid') + .get('/storage/my-app/') + .query(true) + .reply(403, 'AccessDenied') + await main([ + 'storage', + 'ls', + 'website/', + '--workspace', + 'my-app', + ...(endpoint === undefined ? [] : ['--endpoint', endpoint]), + ]) + expect(process.exitCode).toBeUndefined() + expect(intended.isDone()).toBe(true) + expect(unrelated.isDone()).toBe(false) +}) + +test.each([ + '', + '', + 'my-appother-app', +])('refuses incomplete or ambiguous workspace discovery even with an override: %s', async (body) => { + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'my-app', public: [], images: {} }), + ) + const discovery = nock('http://storage.invalid').get('/storage/').query(true).reply(200, body) + const listing = nock('http://storage.invalid') + .get('/storage/my-app/') + .query(true) + .reply(200, 'false') + await main([ + 'storage', + 'ls', + 'website/', + '--workspace', + 'my-app', + '--endpoint', + 'http://storage.invalid', + ]) + expect(discovery.isDone()).toBe(true) + expect(listing.isDone()).toBe(false) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Expected one workspace from Storage discovery; verify the endpoint and Auth Key.', + ) +}) + +test('lists the key workspace and follows signed S3 continuation tokens without an Assembly', async () => { + const api = nock('http://storage.invalid', { + reqheaders: { + authorization: (value: string) => value.startsWith('AWS4-HMAC-SHA256 Credential=local-key/'), + }, + }) + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) + .get('/storage/my-app/') + .query( + (query) => + query.prefix === 'website/' && query['list-type'] === '2' && !query['continuation-token'], + ) + .reply( + 200, + 'truenext-pagewebsite/a.jpg123"hash-a"', + ) + .get('/storage/my-app/') + .query((query) => query.prefix === 'website/' && query['continuation-token'] === 'next-page') + .reply( + 200, + 'falsewebsite/b.jpg456', + ) + await main(['storage', 'ls', 'website/', '--endpoint', 'http://storage.invalid']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith(expect.stringContaining('website/a.jpg'), [ + { path: 'website/a.jpg', size: 123, etag: '"hash-a"' }, + { path: 'website/b.jpg', size: 456 }, + ]) +}) + +test('rejects a truncated listing with a missing cursor instead of silently showing partial data', async () => { + const api = nock('http://storage.invalid') + .get('/storage/my-app/') + .query(true) + .reply(200, 'true') + await main([ + 'storage', + 'ls', + 'website/', + '--workspace', + 'my-app', + '--endpoint', + 'http://storage.invalid', + ]) + expect(api.isDone()).toBe(true) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('cursor')) + expect(OutputCtl.prototype.print).not.toHaveBeenCalled() +}) + +test('sanitizes remote list errors without exposing signed requests', async () => { + const api = nock('http://storage.invalid') + .get('/storage/my-app/') + .query(true) + .reply(403, 'AccessDeniedsecret-remote-message') + await main([ + 'storage', + 'ls', + 'website/', + '--workspace', + 'my-app', + '--endpoint', + 'http://storage.invalid', + ]) + expect(api.isDone()).toBe(true) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Storage listing failed (HTTP 403). The Storage S3 read API at http://storage.invalid/storage is not enabled or access is denied; HTTP 403 cannot distinguish the two. Check the endpoint, workspace and Auth Key read or dam:write scope.', + ) +}) diff --git a/packages/node/test/unit/cli/storage-public.test.ts b/packages/node/test/unit/cli/storage-public.test.ts new file mode 100644 index 00000000..db913d17 --- /dev/null +++ b/packages/node/test/unit/cli/storage-public.test.ts @@ -0,0 +1,546 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { signParamsSync } from '@transloadit/utils/node' +import nock from 'nock' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { resolveCliConfig } from '../../../src/cli/helpers.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' +import { Transloadit } from '../../../src/Transloadit.ts' + +const origin = 'http://127.0.0.1:3020' +const originalCwd = process.cwd() +const stdoutListeners = process.stdout.listeners('error') +const stderrListeners = process.stderr.listeners('error') +const declared = { + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix: 'website/', + created_at: '2026-09-13T00:00:00Z', + created: true, +} +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'storage-public-')) + process.chdir(directory) + await writeFile( + 'credentials', + `TRANSLOADIT_KEY=combined-key\nTRANSLOADIT_SECRET=local-secret\nTRANSLOADIT_WORKSPACE=my-app\nTRANSLOADIT_WORKSPACE_VERIFIED=true\nTRANSLOADIT_ENDPOINT=${origin}\n`, + { mode: 0o600 }, + ) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + 'TRANSLOADIT_WORKSPACE', + 'TRANSLOADIT_ENDPOINT', + ]) + vi.stubEnv(name, '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + nock.disableNetConnect() +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + nock.cleanAll() + nock.enableNetConnect() + for (const listener of process.stdout.listeners('error')) + if (!stdoutListeners.includes(listener)) process.stdout.off('error', listener) + for (const listener of process.stderr.listeners('error')) + if (!stderrListeners.includes(listener)) process.stderr.off('error', listener) + await rm(directory, { recursive: true, force: true }) +}) + +function signedPrefix(body: string): boolean { + const params = /name="params"\r\n\r\n([^\r\n]+)/.exec(body)?.[1] + const signature = /name="signature"\r\n\r\n([^\r\n]+)/.exec(body)?.[1] + expect(params).toBeDefined() + if (params === undefined) return false + expect(JSON.parse(params)).toMatchObject({ prefix: 'website/', auth: { key: 'combined-key' } }) + expect(signature).toBe(signParamsSync(params, 'local-secret')) + return true +} + +test('publish dry run lists matching objects without publishing or touching the catalog', async () => { + const previous = '{"workspace":"my-app","public":[],"images":{}}\n' + await writeFile('transloadit.images.json', previous) + const api = nock(origin) + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) + .get('/storage/my-app/') + .query((query) => query.prefix === 'website/') + .reply( + 200, + 'falsewebsite/hero.jpg123', + ) + const publish = vi.spyOn(Transloadit.prototype, 'publishStoragePrefix') + await main(['storage', 'publish', 'website/', '--dry-run']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(publish).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'transloadit.images.json']) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('website/hero.jpg'), + expect.any(Object), + ) +}) + +test('public init describes recursive current and future access before publishing', async () => { + await mkdir('app') + vi.spyOn(Transloadit.prototype, 'publishStoragePrefix').mockImplementation(() => { + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + 'Publishing website/ recursively: all current and future objects under this prefix will be public.', + ) + return Promise.resolve(declared) + }) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBeUndefined() +}) + +test.each(['publishStoragePrefix', 'unpublishStoragePrefix'] satisfies (keyof Pick< + Transloadit, + 'publishStoragePrefix' | 'unpublishStoragePrefix' +>)[])('%s forwards a pre-aborted signal without sending a request', async (method) => { + const client = new Transloadit({ + authKey: 'local-key', + authSecret: 'local-secret', + endpoint: origin, + }) + const controller = new AbortController() + controller.abort() + const api = nock(origin) + .intercept('/storage/public_prefixes', method === 'publishStoragePrefix' ? 'POST' : 'DELETE') + .reply( + 200, + method === 'publishStoragePrefix' + ? declared + : { ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', prefix: 'website/', deleted: true }, + ) + await expect(client[method]('website/', { signal: controller.signal })).rejects.toThrow() + expect(api.isDone()).toBe(false) +}) + +test.each([ + 'publish', + 'unpublish', + 'init', +])('%s forwards the catalog interrupt to the publication operation', async (command) => { + await mkdir('app') + let signal: AbortSignal | undefined + const method = command === 'unpublish' ? 'unpublishStoragePrefix' : 'publishStoragePrefix' + vi.spyOn(Transloadit.prototype, method).mockImplementation((_prefix, options) => { + signal = options?.signal + process.emit('SIGINT') + return Promise.reject(new Error('Simulated interrupted publication')) + }) + await main( + command === 'init' + ? ['image', 'init', 'website/', '--public'] + : ['storage', command, 'website/'], + ) + expect(signal?.aborted).toBe(true) + expect(process.exitCode).toBe(1) + expect(await readdir(directory)).toEqual(['app', 'credentials']) +}) + +test.each([ + 'publish', + 'unpublish', +])('%s revalidates project-selected credential files instead of trusting their login marker', async (command) => { + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', '') + await writeFile('.env', 'TRANSLOADIT_CREDENTIALS_FILE=credentials\n') + const catalog = { workspace: 'my-app', public: ['website/'], images: {} } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + const discovery = nock(origin) + .get('/storage/') + .query(true) + .reply( + 200, + 'other-app', + ) + const publication = nock(origin).post('/storage/public_prefixes').reply(200, declared) + const revocation = nock(origin).delete('/storage/public_prefixes').reply(200, { + ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', + prefix: 'website/', + deleted: true, + }) + await main(['storage', command, 'website/']) + expect(discovery.isDone()).toBe(true) + expect(publication.isDone()).toBe(false) + expect(revocation.isDone()).toBe(false) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Project uses my-app; the selected credentials belong to other-app. Nothing uploaded.', + ) + expect(resolveCliConfig()).toMatchObject({ + authSource: 'project-selected credentials file', + credentialsSource: 'project-selected credentials file', + authWorkspaceVerified: false, + credentialsWorkspaceVerified: false, + }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) +}) + +test('init recovery advice follows the selected saved login, not unrelated shell credentials', async () => { + await mkdir('app') + vi.stubEnv('TRANSLOADIT_KEY', 'shell-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'shell-secret') + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'other-app') + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(403, { + error: 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY', + message: 'unsafe local-secret', + }) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('https://transloadit.com/c/my-app/template-credentials/'), + ) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toMatch( + /other-app|local-secret|shell-secret/, + ) + expect(await readdir(directory)).toEqual(['app', 'credentials']) +}) + +test('revalidates the saved workspace after a shell endpoint override before publication', async () => { + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'my-app', public: [], images: {} }), + ) + vi.stubEnv('TRANSLOADIT_ENDPOINT', 'http://override.invalid') + const discovery = nock('http://override.invalid') + .get('/storage/') + .query(true) + .reply( + 200, + 'other-app', + ) + const publication = nock('http://override.invalid') + .post('/storage/public_prefixes') + .reply(200, declared) + await main(['storage', 'publish', 'website/']) + expect(discovery.isDone()).toBe(true) + expect(publication.isDone()).toBe(false) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Project uses my-app; the selected credentials belong to other-app. Nothing uploaded.', + ) +}) + +test('an overriding bearer token receives actionable key-selection advice', async () => { + vi.stubEnv('TRANSLOADIT_AUTH_TOKEN', 'shell-bearer') + await main(['storage', 'publish', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Unset TRANSLOADIT_AUTH_TOKEN'), + ) + expect(await readdir(directory)).toEqual(['credentials']) +}) + +test('private write-env requires the saved login instead of persisting transient shell secrets', async () => { + await mkdir('app') + await writeFile('credentials', '') + vi.stubEnv('TRANSLOADIT_KEY', 'shell-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'shell-secret') + vi.stubEnv('TRANSLOADIT_ENDPOINT', origin) + const discovery = nock(origin) + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) + await main(['image', 'init', 'website/', '--private', '--write-env']) + expect(process.exitCode).toBe(1) + expect(discovery.isDone()).toBe(false) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Run transloadit auth login first to save your workspace and Auth Key. Nothing was written.', + ) + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(directory)).toEqual(['app', 'credentials']) +}) + +test('public init commits the whole project catalog without creating an env file', async () => { + await mkdir('app') + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(200, declared) + await main(['image', 'init', 'website', '--public']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images: {}, + delivery: { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '@transloadit/img/next'", + ) + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).not.toContain( + 'TRANSLOADIT_WORKSPACE', + ) +}) + +test('init requires an explicit public or private choice before creating anything', async () => { + await mkdir('app') + await main(['image', 'init', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Choose --example or --private; normal uploads need only storage store', + ) + expect(await readdir(directory)).toEqual(['app', 'credentials']) +}) + +test.each([ + 'publish', + 'unpublish', +])('%s refuses a different project workspace before remote writes', async (command) => { + const catalog = { workspace: 'project-app', public: [], images: {} } + await writeFile('transloadit.images.json', `${JSON.stringify(catalog)}\n`) + await main(['storage', command, 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Project uses project-app; the selected credentials belong to my-app. Nothing uploaded.', + ) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual(catalog) +}) + +test('publish and unpublish update the committed policy without losing image receipts', async () => { + const images = { 'website/hero.jpg': { path: 'website/hero.jpg', width: 100, height: 80 } } + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'my-app', public: [], images }), + ) + const api = nock(origin) + .post('/storage/public_prefixes', signedPrefix) + .reply(200, declared) + .delete('/storage/public_prefixes', signedPrefix) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', prefix: 'website/', deleted: true }) + await main(['storage', 'publish', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images, + delivery: { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) + await main(['storage', 'unpublish', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: [], + images, + delivery: { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) +}) + +test.each([ + 'website', + 'website/', +])('publishes the normalized directory %s with an ordinary signed API request', async (prefix) => { + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(200, declared) + await main(['storage', 'publish', prefix]) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Published website/'), + declared, + ) +}) + +test('revokes a prefix while explaining that cached bytes cannot be recalled', async () => { + const revoked = { ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', prefix: 'website/', deleted: true } + const api = nock(origin).delete('/storage/public_prefixes', signedPrefix).reply(200, revoked) + await main(['storage', 'unpublish', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringMatching(/cached.*cannot be recalled/), + revoked, + ) +}) + +test('lists public prefixes through signed GET, not the S3 controller', async () => { + const listed = { + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [{ prefix: 'website/', created_at: declared.created_at }], + } + const api = nock(origin) + .get('/storage/public_prefixes') + .query((query) => { + if (typeof query.params !== 'string') return false + expect(query.signature).toBe(signParamsSync(query.params, 'local-secret')) + return true + }) + .reply(200, listed) + await main(['storage', 'publications']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('website/'), + listed, + ) +}) + +test('an invalid signing algorithm uses normal CLI error reporting without a stack or raw input', async () => { + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=combined-key\nTRANSLOADIT_SECRET=local-secret\nTRANSLOADIT_SIGNATURE_ALGORITHM=invalid-private-value\n', + ) + await main(['storage', 'publications', '--json']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledExactlyOnceWith( + 'Unsupported TRANSLOADIT_SIGNATURE_ALGORITHM in CLI credentials', + ) + expect(JSON.stringify(vi.mocked(process.stdout.write).mock.calls)).not.toMatch( + /TypeError|helpers\.ts|invalid-private-value|local-secret/, + ) +}) + +test('write-env reports the saved credentials read failure before asking for another login', async () => { + await mkdir('app') + const unreadable = join(directory, 'unreadable-credentials') + await mkdir(unreadable) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', unreadable) + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect(process.exitCode).toBe(1) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(`Failed to read ${unreadable}`) + expect(message).not.toContain('auth login first') + expect(await readdir(directory)).toEqual(['app', 'credentials', 'unreadable-credentials']) +}) + +test('missing Smart CDN enablement links to the workspace key settings without echoing upstream content', async () => { + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(403, { + error: 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY', + message: 'unsafe local-secret', + }) + await main(['storage', 'publish', 'website/']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching( + /Enable Smart CDN.*https:\/\/transloadit.com\/c\/my-app\/template-credentials\//, + ), + ) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'local-secret', + ) +}) + +test.each([ + '', + '/', + '/website/', + '../', + 'a//b/', + `${'a'.repeat(512)}/`, +])('rejects unsafe public prefix %j before making a request', async (prefix) => { + await main(['storage', 'publish', prefix]) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringMatching(/prefix|directory/)) +}) + +test('init publishes first and reuses the saved login without any terminal input', async () => { + await mkdir('app') + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(200, declared) + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '@transloadit/img/next'", + ) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images: {}, + delivery: { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.print).mock.calls)).not.toContain( + 'local-secret', + ) +}) + +test('public prefix limits count UTF-8 bytes before making a request', async () => { + await main(['storage', 'publish', 'é'.repeat(256)]) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'A public prefix must be at most 512 UTF-8 bytes', + ) +}) + +test('a public prefix at exactly 512 UTF-8 bytes is accepted', async () => { + const prefix = `${'é'.repeat(255)}a/` + const api = nock(origin) + .post('/storage/public_prefixes') + .reply(200, { ...declared, prefix }) + await main(['storage', 'publish', prefix]) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) +}) + +test('public init leaves existing env untouched and checks code conflicts before publishing', async () => { + await mkdir('app/storage-image-example', { recursive: true }) + await mkdir('lib') + await writeFile('app/storage-image-example/page.tsx', 'existing page\n') + await writeFile('lib/storageImage.ts', 'existing code\n') + await writeFile('.env.local', 'existing\n') + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect(process.exitCode).toBe(1) + expect(await readFile('.env.local', 'utf8')).toBe('existing\n') + expect(await readdir(directory)).toEqual(['.env.local', 'app', 'credentials', 'lib']) +}) + +test('write-env and publication use the saved login together despite stale project or shell credentials', async () => { + await mkdir('app') + await writeFile( + '.env', + 'TRANSLOADIT_KEY=project-key\nTRANSLOADIT_SECRET=project-secret\nTRANSLOADIT_ENDPOINT=http://127.0.0.1:9\n', + ) + vi.stubEnv('TRANSLOADIT_KEY', 'shell-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'shell-secret') + const api = nock(origin).post('/storage/public_prefixes', signedPrefix).reply(200, declared) + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + const catalog = await readFile('transloadit.images.json', 'utf8') + expect(JSON.parse(catalog).workspace).toBe('my-app') + expect(catalog).not.toMatch(/project-|shell-|local-secret/) +}) + +test('a refused public declaration leaves no misleading factory or env file', async () => { + await mkdir('app') + const api = nock(origin) + .post('/storage/public_prefixes', signedPrefix) + .reply(403, { error: 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY' }) + await main(['image', 'init', 'website/', '--public', '--write-env']) + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readdir(directory)).toEqual(['app', 'credentials']) +}) diff --git a/packages/node/test/unit/cli/storage-receipts-sync.test.ts b/packages/node/test/unit/cli/storage-receipts-sync.test.ts new file mode 100644 index 00000000..d825970d --- /dev/null +++ b/packages/node/test/unit/cli/storage-receipts-sync.test.ts @@ -0,0 +1,744 @@ +import { + chmod, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout } from 'node:timers' +import { setTimeout as delay } from 'node:timers/promises' + +import nock from 'nock' +import { afterEach, beforeEach, expect, onTestFinished, test, vi } from 'vitest' + +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { ...original, rename: vi.fn(original.rename) } +}) + +const originalCwd = process.cwd() +const stdoutListeners = process.stdout.listeners('error') +const stderrListeners = process.stderr.listeners('error') +const md5 = 'd41d8cd98f00b204e9800998ecf8427e' +const metadata = { 'x-amz-meta-dam-width': '800', 'x-amz-meta-dam-height': '600' } +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'storage-receipts-sync-')) + process.chdir(directory) + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=local-key\nTRANSLOADIT_SECRET=local-secret\nTRANSLOADIT_ENDPOINT=http://storage.invalid\n', + ) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + 'TRANSLOADIT_ENDPOINT', + ]) + vi.stubEnv(name, '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + nock.disableNetConnect() +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + nock.cleanAll() + nock.enableNetConnect() + for (const listener of process.stdout.listeners('error')) + if (!stdoutListeners.includes(listener)) process.stdout.off('error', listener) + for (const listener of process.stderr.listeners('error')) + if (!stderrListeners.includes(listener)) process.stderr.off('error', listener) + await rm(directory, { force: true, recursive: true }) +}) + +function runSync(extra: string[] = []): Promise { + return main(['storage', 'receipts', 'sync', 'website/', '--receipts', 'images.json', ...extra]) +} + +function storageApi(origin = 'http://storage.invalid'): nock.Scope { + nock(origin) + .get('/storage/public_prefixes') + .query((query) => { + // Nock can inspect query matchers before choosing the matching path. + if (typeof query.params !== 'string') return false + expect(JSON.parse(query.params)).toMatchObject({ auth: { key: 'local-key' } }) + return true + }) + .optionally() + .reply(200, { + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [{ prefix: 'website/', created_at: '2026-09-14T00:00:00Z' }], + }) + return nock(origin, { + reqheaders: { + authorization: (value: string) => value.startsWith('AWS4-HMAC-SHA256 Credential=local-key/'), + }, + }) + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) +} + +function listed(path = 'website/a.jpg'): nock.Scope { + return storageApi() + .get('/storage/my-app/') + .query((query) => query.prefix === 'website/') + .reply( + 200, + `false${path}123`, + ) +} + +test('a fresh sync recovers the declared delivery policy, not just image dimensions', async () => { + listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images: { 'website/a.jpg': { path: 'website/a.jpg', width: 800, height: 600 } }, + delivery: { + baseUrl: 'http://storage.invalid/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + }) +}) + +test.each([ + true, + false, +])('sync retains a local ThumbHash only while the original MD5 matches (%s)', async (matches) => { + const thumbhash = '1QcSHQRnh493V4dIh4eXh1h4kJUI' + await writeFile( + 'images.json', + JSON.stringify({ + workspace: 'my-app', + public: ['website/'], + images: { + 'website/a.jpg': { + path: 'website/a.jpg', + width: 800, + height: 600, + md5hash: md5, + thumbhash, + hasAlpha: true, + source: 'local-photo.png', + }, + }, + }), + ) + listed() + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { ...metadata, ETag: `"${matches ? md5 : 'a'.repeat(32)}"` }) + await runSync() + expect(process.exitCode).toBeUndefined() + const image = JSON.parse(await readFile('images.json', 'utf8')).images['website/a.jpg'] + if (matches) expect(image).toMatchObject({ thumbhash, hasAlpha: true, source: 'local-photo.png' }) + else { + expect(image).not.toHaveProperty('thumbhash') + expect(image).not.toHaveProperty('hasAlpha') + expect(image).not.toHaveProperty('source') + } +}) + +test.each([ + false, + true, +])('unreadable policy preserves the catalog (existing: %s)', async (existing) => { + const previous = catalogJson({ 'older.jpg': { retained: true } }) + if (existing) await writeFile('images.json', previous) + nock('http://storage.invalid') + .get('/storage/public_prefixes') + .query(true) + .reply(403, { error: 'INSUFFICIENT_AUTH_SCOPE', message: 'remote-secret-must-not-leak' }) + listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Recovery incomplete: could not read the server public prefixes'), + ) + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'remote-secret', + ) + if (existing) expect(await readFile('images.json', 'utf8')).toBe(previous) + else await expect(readFile('images.json')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(directory)).not.toContain('images.json.lock') +}) + +test('sync replaces stale local policy with the server declarations, including a private workspace', async () => { + await writeFile( + 'images.json', + JSON.stringify({ workspace: 'my-app', public: ['website/'], images: {} }), + ) + nock('http://storage.invalid') + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).public).toEqual([]) + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + expect.stringContaining('No public prefixes are declared on the server'), + ) + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + expect.stringMatching(/storage publish.*authorize/), + ) +}) + +test.each([ + 'discovery', + 'listing', + 'HEAD', + 'policy', +])('Ctrl-C cancels a stalled %s and releases the catalog lock', async (stage) => { + nock.enableNetConnect('127.0.0.1') + const listeners = process.listeners('SIGINT') + const registrations = vi.spyOn(process, 'once') + const previous = catalogJson({ 'other.jpg': { retained: true } }) + await writeFile('images.json', previous) + let stalled = false + const server = createServer((request, response) => { + if (stage !== 'discovery' && request.url?.split('?')[0] === '/storage/') { + response.end( + 'my-app', + ) + return + } + if (stage === 'policy' && request.method === 'HEAD') { + response.writeHead(200, metadata).end() + return + } + if ( + (stage === 'HEAD' || stage === 'policy') && + request.url?.startsWith('/storage/my-app/') && + request.method === 'GET' + ) { + response.end( + 'falsewebsite/a.jpg123', + ) + return + } + stalled = true + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('Expected a local port') + const pending = runSync(['--endpoint', `http://127.0.0.1:${address.port}`]) + try { + await expect.poll(() => stalled).toBe(true) + const cancel = registrations.mock.calls.find(([event]) => event === 'SIGINT')?.[1] + expect(cancel).toBeTypeOf('function') + process.emit('SIGINT') + expect(await Promise.race([pending.then(() => 'finished'), delay(3000, 'stalled')])).toBe( + 'finished', + ) + expect(process.exitCode).toBe(1) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) + // The CLI's first dynamic imports may add a dependency's own process listeners. + expect(process.listeners('SIGINT')).toEqual(expect.arrayContaining(listeners)) + expect(process.listeners('SIGINT')).not.toContain(cancel) + expect(process.listeners('SIGTERM')).not.toContain(cancel) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('canceled')) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await pending + } +}) + +test('defaults the rendering catalog to transloadit.images.json', async () => { + const api = listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBeUndefined() + expect( + JSON.parse(await readFile('transloadit.images.json', 'utf8')).images['website/a.jpg'], + ).toMatchObject({ + width: 800, + height: 600, + }) + expect(api.isDone()).toBe(true) +}) + +test.each([ + { command: ['ls'] }, + { command: ['receipts', 'sync'] }, +])('storage $command refuses a different key workspace before listing objects', async ({ + command, +}) => { + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'project-app', public: [], images: {} }), + ) + const discovery = storageApi() + await main(['storage', ...command, 'website/']) + expect(discovery.isDone()).toBe(true) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Project uses project-app; the selected credentials belong to my-app. Nothing uploaded.', + ) + expect(OutputCtl.prototype.print).not.toHaveBeenCalled() +}) + +test('an explicit other workspace never retains upload evidence or claims to update the project catalog', async () => { + const previous = JSON.stringify({ + workspace: 'other-app', + public: ['website/'], + images: { + 'website/a.jpg': { + path: 'website/a.jpg', + width: 800, + height: 600, + md5hash: md5, + asset_id: 'other-workspace-id', + size: 123, + }, + }, + }) + await writeFile('images.json', previous) + const api = listed() + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { ...metadata, etag: `"${md5}"` }) + await runSync(['--workspace', 'my-app']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(await readFile('images.json', 'utf8')).toBe(previous) + const printed = vi.mocked(OutputCtl.prototype.print).mock.calls[0] + expect(printed?.[1]).toEqual({ + 'website/a.jpg': { path: 'website/a.jpg', width: 800, height: 600, md5hash: md5 }, + }) + expect(printed?.[0]).toContain('Catalog unchanged') +}) + +test('rebuilds a rendering catalog from paginated List + HEAD without asset IDs or image GETs', async () => { + const api = storageApi() + .get('/storage/my-app/') + .query((query) => query.prefix === 'website/' && !query['continuation-token']) + .reply( + 200, + 'truenextwebsite/a.jpg123"stale-list-etag"', + ) + .get('/storage/my-app/') + .query((query) => query['continuation-token'] === 'next') + .reply( + 200, + 'falsewebsite/b.jpg456', + ) + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { ...metadata, etag: `"${md5}"` }) + .head('/storage/my-app/website/b.jpg') + .reply(200, '', { + 'x-amz-meta-dam-width': '1200', + 'x-amz-meta-dam-height': '900', + etag: '"multipart-2"', + }) + await runSync() + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + const expected = { + 'website/a.jpg': { path: 'website/a.jpg', width: 800, height: 600, md5hash: md5 }, + 'website/b.jpg': { path: 'website/b.jpg', width: 1200, height: 900 }, + } + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual(expected) + expect(await readFile('images.json', 'utf8')).toMatch(/\n$/) + expect(await readdir(directory)).toEqual([ + 'credentials', + 'images.json', + 'transloadit-images.d.ts', + ]) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Synced 2'), + expected, + ) +}) + +test.each([ + 0o022, 0o077, +])('creates a catalog using umask %i without changing credentials', async (mask) => { + // This CLI suite runs in a separate process, just like its existing chdir-based fixtures. + const setMask = process.umask + const previousMask = setMask(mask) + onTestFinished(() => { + setMask(previousMask) + }) + const maskRead = vi.spyOn(process, 'umask') + await chmod('credentials', 0o600) + const api = listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect((await stat('images.json')).mode & 0o777).toBe(0o666 & ~mask) + expect((await stat('credentials')).mode & 0o777).toBe(0o600) + expect(maskRead).not.toHaveBeenCalled() +}) + +test.each([ + 'ls', + 'ls-body', + 'sync-discovery', + 'sync-list', + 'sync-body', + 'sync-head', +])('aborts stalled %s requests and releases the catalog lock without replacing its contents', async (operation) => { + nock.enableNetConnect('127.0.0.1') + // Exercise Smithy's real HTTP handler and retries with accelerated header/body deadlines. + vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback, timeout, ...args) => + setTimeout(callback, timeout === 30_000 ? 100 : timeout, ...args), + ) + const deadline = AbortSignal.timeout + vi.spyOn(AbortSignal, 'timeout').mockImplementation((timeout) => + deadline(timeout === 60_000 ? 1000 : timeout), + ) + let stalledRequests = 0 + const server = createServer((request, response) => { + // Bound catalogs discover ownership before listing, even with an explicit workspace. + if ( + operation.startsWith('sync-') && + operation !== 'sync-discovery' && + request.url?.split('?')[0] === '/storage/' + ) { + response.end( + 'my-app', + ) + return + } + if (operation === 'sync-head' && request.method === 'GET') { + response.end( + 'falsewebsite/a.jpg123', + ) + return + } + if (operation.endsWith('-body')) response.write('') + stalledRequests += 1 + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('Expected a local port') + const previous = catalogJson({ 'other.jpg': { owner: 'app' } }) + await writeFile('images.json', previous) + const options = [ + '--endpoint', + `http://127.0.0.1:${address.port}`, + ...(operation === 'sync-discovery' ? [] : ['--workspace', 'my-app']), + ] + const command = operation.startsWith('ls') + ? main(['storage', 'ls', 'website/', ...options]) + : runSync(options) + try { + expect(await Promise.race([command.then(() => 'finished'), delay(3000, 'stalled')])).toBe( + 'finished', + ) + expect(process.exitCode).toBe(1) + expect(stalledRequests).toBe(operation.endsWith('-body') ? 1 : 2) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('timed out or lost its connection'), + ) + expect(OutputCtl.prototype.print).not.toHaveBeenCalled() + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await command + } +}) + +test.each([ + undefined, + 'http://override.invalid', +])('keeps endpoint and credentials together with explicit override %j', async (endpoint) => { + await writeFile( + '.env', + 'TRANSLOADIT_AUTH_TOKEN=unrelated\nTRANSLOADIT_ENDPOINT=http://token.invalid\n', + ) + const api = storageApi(endpoint) + .get('/storage/my-app/') + .query(true) + .reply(200, 'false') + await runSync(endpoint === undefined ? [] : ['--endpoint', endpoint]) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual({}) +}) + +test.each([ + md5, + md5.toUpperCase(), +])('preserves upload evidence when the HEAD MD5 still matches %s', async (previousHash) => { + const receipt = { + path: 'website/a.jpg', + width: 600, + height: 800, + md5hash: previousHash, + asset_id: 'verified-asset', + size: 123, + } + await writeFile('images.json', catalogJson({ [receipt.path]: receipt })) + const api = listed() + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { ...metadata, etag: `"${md5}"` }) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(JSON.parse(await readFile('images.json', 'utf8')).images[receipt.path]).toEqual({ + ...receipt, + width: 800, + height: 600, + md5hash: md5, + }) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Synced 1 rendering receipts and public policy'), + expect.anything(), + ) +}) + +test('refreshes matched entries without stale upload fields and preserves unmatched records verbatim', async () => { + const previous = { + ['__proto__']: { path: '__proto__' }, + 'other/a.jpg': { owner: 'app' }, + 'website/deleted.jpg': { kept: true }, + 'website/a.jpg': { asset_id: 'old', size: 999, md5hash: 'stale' }, + } + await writeFile('images.json', catalogJson(previous)) + await chmod('images.json', 0o640) + const api = listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual({ + ...previous, + 'website/a.jpg': { path: 'website/a.jpg', width: 800, height: 600 }, + }) + expect((await stat('images.json')).mode & 0o777).toBe(0o640) +}) + +test.each([ + [{ etag: `"${md5}"` }, md5], + [{ etag: md5.toUpperCase() }, md5], + [{ etag: `"${md5}"`, 'x-amz-server-side-encryption': 'AES256' }, md5], + [{ etag: `"${md5}-2"` }, undefined], + [{ etag: `W/"${md5}"` }, undefined], + [{ etag: '"opaque"' }, undefined], + [{ etag: `"${md5}"`, 'x-amz-server-side-encryption': 'aws:kms' }, undefined], + [{ etag: `"${md5}"`, 'x-amz-server-side-encryption-customer-algorithm': 'AES256' }, undefined], +])('only records a compatible single-part MD5 ETag (%j)', async (headers, expectedMd5) => { + const api = listed() + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { ...metadata, ...headers }) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + const receipt = JSON.parse(await readFile('images.json', 'utf8')).images['website/a.jpg'] + expect(receipt.md5hash).toBe(expectedMd5) + expect(receipt).not.toHaveProperty('asset_id') +}) + +test.each([ + undefined, + '', + '0', + '-1', + '1.5', + 'NaN', + 'Infinity', + '1e3', + '9007199254740992', +])('fails atomically for missing/invalid image dimensions %j', async (width) => { + const previous = catalogJson({ unrelated: { keep: true } }) + await writeFile('images.json', previous) + const api = listed() + .head('/storage/my-app/website/a.jpg') + .reply(200, '', { + 'x-amz-meta-dam-height': '600', + ...(width === undefined ? {} : { 'x-amz-meta-dam-width': width }), + }) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/website\/a.jpg.*dam-width.*dam-height/), + ) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) +}) + +test.each([ + 'true', + 'trueloop', +])('rejects incomplete/repeated pagination before changing the file', async (cursor) => { + const previous = catalogJson({ keep: true }) + await writeFile('images.json', previous) + const api = storageApi() + .get('/storage/my-app/') + .query(true) + .times(cursor.includes('loop') ? 2 : 1) + .reply(200, `${cursor}`) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('cursor')) + expect(await readFile('images.json', 'utf8')).toBe(previous) +}) + +test.each([ + 'other/a.jpg', + 'website/../a.jpg', +])('rejects an unexpected or unsafe listed path %s without writing', async (path) => { + const api = listed(path) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining(JSON.stringify(path)), + ) + expect(await readdir(directory)).toEqual(['credentials']) +}) + +test.each([ + 403, 404, +])('identifies a failed HEAD (HTTP %i) safely and preserves the entire previous catalog', async (status) => { + const previous = catalogJson({ keep: true }) + await writeFile('images.json', previous) + const api = listed() + .head('/storage/my-app/website/a.jpg') + .reply(status, '', { 'x-amz-error-message': 'local-secret' }) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining(`Storage HEAD failed for "website/a.jpg" (HTTP ${status})`), + ) + if (status === 403) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining( + 'The Storage S3 read API at http://storage.invalid/storage is not enabled or access is denied; HTTP 403 cannot distinguish the two.', + ), + ) + expect(vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join(' ')).not.toContain( + 'local-secret', + ) + expect(await readFile('images.json', 'utf8')).toBe(previous) +}) + +test('a denied S3 listing names the endpoint and both possible causes without changing the catalog', async () => { + const previous = catalogJson({ keep: true }) + await writeFile('images.json', previous) + const api = storageApi() + .get('/storage/my-app/') + .query(true) + .reply(403, 'AccessDeniedlocal-secret') + await runSync() + expect(api.isDone()).toBe(true) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Storage receipt sync failed (HTTP 403). The Storage S3 read API at http://storage.invalid/storage is not enabled or access is denied; HTTP 403 cannot distinguish the two. Check the endpoint, workspace and Auth Key read or dam:write scope.', + ) + expect(await readFile('images.json', 'utf8')).toBe(previous) +}) + +test('does not save an earlier successful HEAD when a later image lacks height', async () => { + const previous = catalogJson({ keep: true }) + await writeFile('images.json', previous) + const api = storageApi() + .get('/storage/my-app/') + .query(true) + .reply( + 200, + 'falsewebsite/a.jpg123website/b.jpg456', + ) + .head('/storage/my-app/website/a.jpg') + .reply(200, '', metadata) + .head('/storage/my-app/website/b.jpg') + .delay(30) + .reply(200, '', { 'x-amz-meta-dam-width': '100' }) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('website/b.jpg')) + expect(await readFile('images.json', 'utf8')).toBe(previous) +}) + +test('rejects duplicate paths instead of choosing an arbitrary HEAD response', async () => { + const api = storageApi() + .get('/storage/my-app/') + .query(true) + .reply( + 200, + 'falsewebsite/a.jpg123website/a.jpg123', + ) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('duplicate path')) + expect(await readdir(directory)).toEqual(['credentials']) +}) + +test.each([ + 'locked', + 'invalid JSON', + 'symlink', +])('refuses %s receipts before any network requests', async (kind) => { + if (kind === 'locked') await writeFile('images.json.lock', '') + else if (kind === 'symlink') await symlink('credentials', 'images.json') + else await writeFile('images.json', '{') + const api = storageApi() + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(false) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining(kind === 'symlink' ? 'regular JSON file' : kind), + ) + if (kind !== 'locked') expect(await readdir(directory)).not.toContain('images.json.lock') +}) + +test('retains the new complete catalog and releases its lock if atomic replacement fails', async () => { + const previous = catalogJson({ keep: true }) + await writeFile('images.json', previous) + vi.mocked(rename).mockRejectedValueOnce(new Error('EACCES: rename denied')) + const api = listed().head('/storage/my-app/website/a.jpg').reply(200, '', metadata) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(await readFile('images.json', 'utf8')).toBe(previous) + const files = await readdir(directory) + expect(files).not.toContain('images.json.lock') + const temporary = files.find((name) => name.endsWith('.tmp')) + expect(temporary).toBeDefined() + if (temporary === undefined) throw new Error('Expected retained complete catalog') + expect(JSON.parse(await readFile(temporary, 'utf8')).images).toEqual({ + keep: true, + 'website/a.jpg': { path: 'website/a.jpg', width: 800, height: 600 }, + }) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining(temporary)) +}) + +function catalogJson(images: Record): string { + return `${JSON.stringify({ workspace: 'my-app', public: [], images })}\n` +} diff --git a/packages/node/test/unit/cli/storage-store.test.ts b/packages/node/test/unit/cli/storage-store.test.ts new file mode 100644 index 00000000..1fcfc3a0 --- /dev/null +++ b/packages/node/test/unit/cli/storage-store.test.ts @@ -0,0 +1,1305 @@ +import { createHash } from 'node:crypto' +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { ApiError } from '../../../src/ApiError.ts' +import OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' +import { Transloadit } from '../../../src/Transloadit.ts' + +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + readFile: vi.fn(original.readFile), + rename: vi.fn(original.rename), + rm: vi.fn(original.rm), + } +}) + +const originalCwd = process.cwd() +const stdoutErrorListeners = process.stdout.listeners('error') +const stderrErrorListeners = process.stderr.listeners('error') +const receipt = { + asset_id: 'stored-asset', + height: 600, + md5hash: 'd41d8cd98f00b204e9800998ecf8427e', + path: 'website/hero.jpg', + size: 1234, + width: 800, +} +let directory: string + +beforeEach(async () => { + vi.mocked(rm).mockReset() + directory = await mkdtemp(join(tmpdir(), 'cli-storage-store-')) + await writeFile(join(directory, 'credentials'), '') + process.chdir(directory) + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + vi.stubEnv('TRANSLOADIT_KEY', 'assembly-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'assembly-secret') + vi.stubEnv('TRANSLOADIT_AUTH_TOKEN', '') + vi.stubEnv('TRANSLOADIT_AUTH_KEY', '') + vi.stubEnv('TRANSLOADIT_AUTH_SECRET', '') + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(OutputCtl.prototype, 'error').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'notice').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'warn').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'debug').mockImplementation(() => {}) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + nock.disableNetConnect() + nock('https://api2.transloadit.com') + .persist() + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) +}) + +afterEach(async () => { + process.chdir(originalCwd) + process.exitCode = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + nock.cleanAll() + nock.enableNetConnect() + // OutputCtl installs stream listeners per CLI invocation; do not leak them between tests. + for (const listener of process.stdout.listeners('error')) { + if (!stdoutErrorListeners.includes(listener)) process.stdout.off('error', listener) + } + for (const listener of process.stderr.listeners('error')) { + if (!stderrErrorListeners.includes(listener)) process.stderr.off('error', listener) + } + await rm(directory, { recursive: true, force: true }) +}) + +function runStore(path = receipt.path): Promise { + return main(['storage', 'store', './hero.jpg', path, '--receipts', 'images.json']) +} + +describe('storage store', () => { + test.each([ + ['website/hero.jpg', 'website/hero.HASH.jpg'], + ['website/', 'website/local-photo.HASH.jpg'], + ['website/v1.2/hero.large.png', 'website/v1.2/hero.large.HASH.png'], + ['website/hero', 'website/hero.HASH'], + ['website/.hero', 'website/.hero.HASH'], + ])('hashes %s before the extension and uses that identity throughout the catalog', async (target, pattern) => { + const bytes = Buffer.from('original image bytes') + const md5hash = createHash('md5').update(bytes).digest('hex') + const path = pattern.replace('HASH', md5hash.slice(0, 8)) + const stored = { ...receipt, path, md5hash, size: bytes.length } + await writeFile('local-photo.jpg', bytes) + const create = vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [{ ...stored, meta: { width: 800, height: 600 } }] }, + }) + await main(['storage', 'store', './local-photo.jpg', target, '--hashed']) + expect(process.exitCode).toBeUndefined() + expect(create).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + params: { + steps: { + stored: { + robot: '/transloadit/store', + use: ':original', + path, + conflict_strategy: 'error', + }, + }, + }, + }), + ) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + expect(catalog.images).toEqual({ + [path]: { ...stored, source: 'local-photo.jpg', apiOrigin: 'https://api2.transloadit.com' }, + }) + expect(await readFile('transloadit-images.d.ts', 'utf8')).toContain( + `"${path}": { path: "${path}";`, + ) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining(` { + const bytes = Buffer.from('first') + const changed = Buffer.from('replacement') + const md5hash = createHash('md5').update(bytes).digest('hex') + const nextHash = createHash('md5').update(changed).digest('hex') + const first = { + ...receipt, + path: `website/hero.${md5hash.slice(0, 8)}.jpg`, + md5hash, + size: bytes.length, + source: 'hero.jpg', + apiOrigin: 'https://api2.transloadit.com', + } + const second = { + ...first, + path: `website/hero.${nextHash.slice(0, 8)}.jpg`, + md5hash: nextHash, + size: changed.length, + } + await writeFile('hero.jpg', bytes) + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + const args = ['storage', 'store', './hero.jpg', 'website/', '--hashed'] + await main(args) + expect(process.exitCode).toBeUndefined() + const saved = await readFile('transloadit.images.json', 'utf8') + await main(args) + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledOnce() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(saved) + expect(OutputCtl.prototype.print).toHaveBeenLastCalledWith( + expect.stringContaining(`Unchanged ${first.path}; no upload needed.`), + first, + ) + await writeFile('hero.jpg', changed) + await main(args) + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledTimes(2) + expect(store.mock.calls[1]?.[1]).toMatchObject({ path: second.path }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).images).toEqual({ + [first.path]: first, + [second.path]: second, + }) + }) + + test.each([ + 'checksum', + 'size', + 'path', + 'dimensions', + ])('does not reuse a hashed receipt with different %s', async (difference) => { + const bytes = Buffer.from('original') + const md5hash = createHash('md5').update(bytes).digest('hex') + const path = `website/hero.${md5hash.slice(0, 8)}.jpg` + const old = { + ...receipt, + apiOrigin: 'https://api2.transloadit.com', + path, + md5hash, + size: bytes.length, + ...(difference === 'checksum' ? { md5hash: `${md5hash.slice(0, 8)}${'0'.repeat(24)}` } : {}), + ...(difference === 'size' ? { size: bytes.length + 1 } : {}), + ...(difference === 'path' ? { path: 'another/path.jpg' } : {}), + ...(difference === 'dimensions' ? { width: 0 } : {}), + } + await writeFile('hero.jpg', bytes) + const previous = catalogJson({ [path]: old }) + await writeFile('transloadit.images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + await main(['storage', 'store', './hero.jpg', 'website/hero.jpg', '--hashed']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining(path)) + expect(OutputCtl.prototype.error).not.toHaveBeenCalledWith( + expect.stringContaining('--overwrite'), + ) + }) + + test('hashed and overwrite cannot be combined', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + await main(['storage', 'store', './hero.jpg', receipt.path, '--hashed', '--overwrite']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('--hashed cannot be combined with --overwrite'), + ) + }) + + test.each([ + 'https://api2-devdock.transloadit.dev', + undefined, + ])('never reuses same-slug receipts from an unverified API origin (%s)', async (apiOrigin) => { + const bytes = Buffer.from('original') + const md5hash = createHash('md5').update(bytes).digest('hex') + const path = `website/hero.${md5hash.slice(0, 8)}.jpg` + const previous = catalogJson({ + [path]: { ...receipt, path, md5hash, size: bytes.length, apiOrigin }, + }) + await writeFile('hero.jpg', bytes) + await writeFile('transloadit.images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + await main(['storage', 'store', './hero.jpg', receipt.path, '--hashed']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.print).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('https://api2.transloadit.com'), + ) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('--receipts')) + }) + + test('deduplicates a hashed batch even when an explicit workspace override leaves the catalog unchanged', async () => { + const bytes = Buffer.from('original') + const md5hash = createHash('md5').update(bytes).digest('hex') + const path = `website/hero.${md5hash.slice(0, 8)}.jpg` + const previous = JSON.stringify({ workspace: 'other-app', public: [], images: {} }) + await writeFile('transloadit.images.json', previous) + await mkdir('a') + await mkdir('b') + await writeFile('a/hero.jpg', bytes) + await writeFile('b/hero.jpg', bytes) + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockResolvedValueOnce({ ...receipt, path, md5hash, size: bytes.length }) + .mockRejectedValueOnce(new ApiError({ body: { error: 'TRANSLOADIT_STORE_CONFLICT' } })) + await main([ + 'storage', + 'store', + './a/hero.jpg', + './b/hero.jpg', + 'website/', + '--hashed', + '--workspace', + 'my-app', + ]) + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledOnce() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.print).toHaveBeenLastCalledWith( + expect.stringContaining(`Unchanged ${path}; no upload needed.`), + expect.objectContaining({ path }), + ) + }) + + test('explains why restoring the same transformed receipt cannot make a hashed replay succeed', async () => { + const bytes = Buffer.from('original') + const hash = createHash('md5').update(bytes).digest('hex').slice(0, 8) + const path = `website/hero.${hash}.jpg` + const stored = { + ...receipt, + path, + size: bytes.length + 27, + md5hash: 'b'.repeat(32), + source: 'hero.jpg', + apiOrigin: 'https://api2.transloadit.com', + } + const previous = catalogJson({ [path]: stored }) + await writeFile('hero.jpg', bytes) + await writeFile('transloadit.images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + await main(['storage', 'store', './hero.jpg', receipt.path, '--hashed']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('transformed the upload'), + ) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Restoring the same receipt will not help'), + ) + expect(OutputCtl.prototype.error).not.toHaveBeenCalledWith( + expect.stringContaining('--overwrite'), + ) + }) + + test('hashed destinations with conflicting remote objects never suggest overwriting', async () => { + await writeFile('hero.jpg', 'original') + const hash = createHash('md5').update('original').digest('hex').slice(0, 8) + vi.spyOn(Transloadit.prototype, 'storeImage').mockRejectedValue( + new ApiError({ body: { error: 'TRANSLOADIT_STORE_CONFLICT' } }), + ) + await main(['storage', 'store', './hero.jpg', receipt.path, '--hashed']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining(`website/hero.${hash}.jpg`), + ) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Restore its catalog receipt'), + ) + expect(OutputCtl.prototype.error).not.toHaveBeenCalledWith( + expect.stringContaining('--overwrite'), + ) + }) + + test('hashed batches accept equal basenames with different contents', async () => { + await mkdir('a') + await mkdir('b') + await writeFile('a/hero.jpg', 'a') + await writeFile('b/hero.jpg', 'b') + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockImplementation(async (file, options) => ({ + ...receipt, + path: options.path, + size: 1, + md5hash: createHash('md5') + .update(await readFile(file)) + .digest('hex'), + })) + await main(['storage', 'store', './a/hero.jpg', './b/hero.jpg', 'website/', '--hashed']) + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledTimes(2) + expect( + Object.keys(JSON.parse(await readFile('transloadit.images.json', 'utf8')).images), + ).toEqual(['website/hero.0cc175b9.jpg', 'website/hero.92eb5ffe.jpg']) + }) + + test('hash suffixes still respect the maximum Storage path length before uploading', async () => { + await writeFile('hero.jpg', 'original') + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + await main(['storage', 'store', './hero.jpg', `${'x'.repeat(1020)}.jpg`, '--hashed']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('1024')) + }) + + test('saves optional ThumbHash metadata without advising blur for a private image', async () => { + const blurred = { ...receipt, hasAlpha: true, thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI' } + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(blurred) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images[receipt.path]).toEqual(blurred) + expect(await readFile('transloadit-images.d.ts', 'utf8')).toContain('thumbhash?: string') + expect(await readFile('transloadit-images.d.ts', 'utf8')).toContain('hasAlpha?: boolean') + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.not.stringContaining('placeholder="blur"'), + blurred, + ) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('This directory is private.'), + blurred, + ) + }) + + test.each([ + { + prefixes: ['website/'], + thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + hasAlpha: false, + blur: true, + }, + { prefixes: ['website/'], thumbhash: undefined, hasAlpha: false, blur: false }, + { + prefixes: ['website/'], + thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + hasAlpha: true, + blur: false, + }, + { + prefixes: ['website/other/'], + thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + hasAlpha: false, + blur: false, + }, + ])('matches rendering advice to existing publication and receipt metadata: %j', async ({ + prefixes, + thumbhash, + hasAlpha, + blur, + }) => { + await writeFile( + 'images.json', + JSON.stringify({ workspace: 'my-app', public: prefixes, images: {} }), + ) + const stored = { ...receipt, thumbhash, ...(hasAlpha ? { hasAlpha: true } : {}) } + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(stored) + await runStore() + expect(process.exitCode).toBeUndefined() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text?.includes('placeholder="blur"')).toBe(blur) + expect(text?.includes('This directory is private.')).toBe(!prefixes.includes('website/')) + }) + + test('private next steps name the files, application key and restart, using the custom catalog', async () => { + await mkdir('src/app', { recursive: true }) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).toContain('transloadit.authorize.ts') + expect(text).toContain('src/app/api/storage-images/route.ts') + expect(text).toContain( + 'npx transloadit image init --private --receipts=images.json -- website/', + ) + expect(text).toContain('TRANSLOADIT_SMART_CDN_KEY/SECRET') + expect(text).toContain('Restart next dev after adding them.') + expect(text).not.toContain('placeholder="blur"') + }) + + test.each([ + 'ts', + 'mjs', + 'js', + ])('prints a missing next.config.%s wrapper without executing or editing it', async (extension) => { + const file = `next.config.${extension}` + const source = 'throw new Error("the CLI must not execute this config")\n' + await writeFile(file, source) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).toContain(`${file} is not wrapped yet`) + expect(text).toContain("import { withTransloaditImages } from '@transloadit/img/next/config'") + expect(text).toContain('export default withTransloaditImages(nextConfig)') + expect(await readFile(file, 'utf8')).toBe(source) + }) + + test('omits wrapper advice when a config already uses the plugin', async () => { + await writeFile('next.config.ts', 'export default withTransloaditImages(nextConfig)\n') + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).not.toContain('is not wrapped yet') + }) + + test('the ESM wrapper advice explains migration from a CommonJS Next config', async () => { + const source = 'module.exports = { reactStrictMode: true }\n' + await writeFile('next.config.js', source) + await writeFile('package.json', '{"type":"commonjs"}\n') + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).toContain('For CommonJS, rename next.config.js to next.config.mjs') + expect(text).toContain('convert require/module.exports to import/export') + expect(await readFile('next.config.js', 'utf8')).toBe(source) + await expect(stat('next.config.mjs')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('does not infer destination privacy from a different workspace catalog left unchanged', async () => { + const previous = JSON.stringify({ workspace: 'other-app', public: ['website/'], images: {} }) + await writeFile('images.json', previous) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main([ + 'storage', + 'store', + './hero.jpg', + receipt.path, + '--receipts', + 'images.json', + '--workspace', + 'my-app', + ]) + expect(process.exitCode).toBeUndefined() + expect(await readFile('images.json', 'utf8')).toBe(previous) + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).toContain('the different-workspace project catalog was left unchanged') + expect(text).not.toContain('This directory is private.') + expect(text).not.toContain('image init') + expect(text).not.toContain('placeholder="blur"') + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + expect.stringContaining('Use --receipts for a separate catalog.'), + ) + }) + + test('prints shared private setup and config advice once for a multi-file upload', async () => { + await writeFile('next.config.ts', 'export default {}\n') + vi.spyOn(Transloadit.prototype, 'storeImage').mockImplementation(async (_file, options) => ({ + ...receipt, + path: options.path, + })) + await main(['storage', 'store', './a.jpg', './b.jpg', 'website/']) + expect(process.exitCode).toBeUndefined() + const output = vi + .mocked(OutputCtl.prototype.print) + .mock.calls.map(([text]) => text) + .join('\n') + expect(output).toContain('Saved website/a.jpg') + expect(output).toContain('Saved website/b.jpg') + expect(output.split('This directory is private.')).toHaveLength(2) + expect(output.split('is not wrapped yet')).toHaveLength(2) + }) + test('generated declarations cannot overwrite the catalog or input image', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main([ + 'storage', + 'store', + './hero.jpg', + receipt.path, + '--receipts', + 'transloadit-images.d.ts', + ]) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + process.exitCode = undefined + await main(['storage', 'store', './transloadit-images.d.ts', receipt.path]) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + }) + + test('a handwritten declarations file is preserved before any upload', async () => { + const previous = "declare module 'my-app' {}\n" + await writeFile('transloadit-images.d.ts', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('transloadit-images.d.ts', 'utf8')).toBe(previous) + }) + + test('upload output names both generated files to commit', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('transloadit-images.d.ts'), + receipt, + ) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Replace alt with a description'), + receipt, + ) + }) + + test('accepts generated declarations after a Windows checkout converts them to CRLF', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const types = await readFile('transloadit-images.d.ts', 'utf8') + await writeFile('transloadit-images.d.ts', types.replaceAll('\n', '\r\n')) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledTimes(2) + expect(await readFile('transloadit-images.d.ts', 'utf8')).toBe(types) + }) + + test('the first public store creates a catalog and generated types without image init', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + const publish = vi + .spyOn(Transloadit.prototype, 'publishStoragePrefix') + .mockImplementation(() => { + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + 'Publishing website/ recursively: all current and future objects under this prefix will be public.', + ) + return Promise.resolve({ + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix: 'website/', + created: false, + created_at: '', + }) + }) + await main(['storage', 'store', './hero.jpg', receipt.path, '--public']) + expect(process.exitCode).toBeUndefined() + expect(publish).toHaveBeenCalledExactlyOnceWith('website/', { signal: expect.any(AbortSignal) }) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: ['website/'], + images: { [receipt.path]: receipt }, + }) + const types = await readFile('transloadit-images.d.ts', 'utf8') + expect(types).toContain("declare module '@transloadit/img/next'") + expect(types).toContain( + '"website/hero.jpg": { path: "website/hero.jpg"; width: 800; height: 600; thumbhash?: string; hasAlpha?: boolean }', + ) + expect(types).not.toMatch(/assembly-key|assembly-secret|stored-asset|md5hash/) + expect(types).toMatch(/\n$/) + expect((await stat('transloadit-images.d.ts')).mode & 0o444).toBe(0o444) + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).not.toContain('This directory is private.') + expect(text).not.toContain('placeholder="blur"') + }) + + test('public store rejects a root object before any upload or policy change', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + const publish = vi.spyOn(Transloadit.prototype, 'publishStoragePrefix') + await main(['storage', 'store', './hero.jpg', 'hero.jpg', '--public']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('non-root directory'), + ) + }) + + test('catalog writes preserve parameter-only delivery and repeated query values', async () => { + const delivery = { urlParams: { cdn: 'required', custom: ['first', 'second'] } } + await writeFile( + 'images.json', + JSON.stringify({ workspace: 'my-app', public: [], images: {}, delivery }), + ) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).delivery).toEqual(delivery) + }) + + test('public store checkpoints a receipt even when publication is denied', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + vi.spyOn(Transloadit.prototype, 'publishStoragePrefix').mockRejectedValue(new Error('denied')) + await main(['storage', 'store', './hero.jpg', receipt.path, '--public']) + expect(process.exitCode).toBe(1) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + public: [], + images: { [receipt.path]: receipt }, + }) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Do not re-upload'), + ) + }) + + test('stores development delivery in the catalog and preserves it on later writes', async () => { + vi.stubEnv('TRANSLOADIT_KEY', '') + vi.stubEnv('TRANSLOADIT_SECRET', '') + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=saved-key\nTRANSLOADIT_SECRET=saved-secret\nTRANSLOADIT_WORKSPACE=my-app\nTRANSLOADIT_WORKSPACE_VERIFIED=true\nTRANSLOADIT_ENDPOINT=http://127.0.0.1:32189\n', + ) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).delivery).toEqual({ + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + }) + vi.stubEnv('TRANSLOADIT_KEY', 'assembly-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'assembly-secret') + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).delivery).toEqual({ + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + }) + }) + + test('sync help recovers the same default catalog that store writes', async () => { + await main(['storage', 'receipts', 'sync', '--help']) + const output = vi.mocked(process.stdout.write).mock.calls.flat().join(' ') + expect(output).toContain('transloadit.images.json') + expect(output).toContain('transloadit storage receipts sync website/') + expect(output).not.toContain('--receipts images.json') + }) + + test('names the checksum change when transformed bytes have the same length', async () => { + await writeFile('hero.jpg', Buffer.alloc(receipt.size, 42)) + vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + assembly_id: 'same-size-assembly', + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [{ ...receipt, meta: { width: receipt.width, height: receipt.height } }], + }, + }) + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.warn).toHaveBeenCalledWith( + expect.stringContaining('same size, different MD5'), + ) + expect( + JSON.parse(await readFile('transloadit.images.json', 'utf8')).images[receipt.path], + ).toEqual(receipt) + }) + + test('saves the Community-plan result and explains changed bytes without suggesting another write', async () => { + await writeFile('hero.jpg', Buffer.alloc(78_593, 42)) + const stored = { ...receipt, size: 71_336, md5hash: 'b'.repeat(32) } + vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + assembly_id: 'watermarked-assembly', + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [{ ...stored, meta: { width: stored.width, height: stored.height } }], + }, + }) + await main(['storage', 'store', './hero.jpg', receipt.path, '--log-level', 'debug']) + expect(process.exitCode).toBeUndefined() + expect( + JSON.parse(await readFile('transloadit.images.json', 'utf8')).images[receipt.path], + ).toEqual(stored) + expect(OutputCtl.prototype.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Stored bytes differ from ./hero.jpg (78,593 → 71,336 bytes); the workspace plan may have transformed the upload', + ), + ) + expect(OutputCtl.prototype.debug).toHaveBeenCalledWith( + expect.stringContaining('watermarked-assembly'), + ) + expect(OutputCtl.prototype.debug).toHaveBeenCalledWith( + expect.stringContaining('"sizeMatches":false'), + ) + expect(OutputCtl.prototype.debug).toHaveBeenCalledWith( + expect.stringContaining('"md5Matches":false'), + ) + expect(OutputCtl.prototype.error).not.toHaveBeenCalled() + expect(OutputCtl.prototype.warn).toHaveBeenCalledWith( + expect.stringContaining('older deployments'), + ) + }) + + test('Ctrl-C aborts an active upload, releases its lock and preserves the previous catalog', async () => { + const listeners = process.listeners('SIGINT') + const previous = catalogJson({ 'website/earlier.jpg': receipt }) + await writeFile('images.json', previous) + vi.spyOn(Transloadit.prototype, 'storeImage').mockImplementation((_file, options) => { + process.emit('SIGINT') + expect(options.signal?.aborted).toBe(true) + options.signal?.throwIfAborted() + return Promise.resolve(receipt) + }) + await runStore() + expect(process.exitCode).toBe(1) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).not.toContain('images.json.lock') + expect(process.listeners('SIGINT')).toEqual(listeners) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('canceled')) + }) + + test('Ctrl-C during atomic replacement preserves the completed receipt and stops the next upload', async () => { + const listeners = process.listeners('SIGINT') + const replace = vi.mocked(rename).getMockImplementation() + if (replace === undefined) throw new Error('Expected real rename implementation') + vi.mocked(rename).mockImplementationOnce(async (from, to) => { + process.emit('SIGINT') + await replace(from, to) + }) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', './next.jpg', 'website/']) + expect(process.exitCode).toBe(1) + expect(store).toHaveBeenCalledTimes(1) + expect( + JSON.parse(await readFile('transloadit.images.json', 'utf8')).images[receipt.path], + ).toEqual(receipt) + expect(await readdir(directory)).not.toContain('transloadit.images.json.lock') + expect(process.listeners('SIGINT')).toEqual(listeners) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Do not re-upload'), + ) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Receipt saved in transloadit.images.json. No further files were uploaded.', + ), + ) + expect(OutputCtl.prototype.error).not.toHaveBeenCalledWith( + expect.stringContaining('recover the verified receipt'), + ) + }) + + test('refuses a stale workspace label on env credentials before uploading', async () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'project-app') + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'project-app', public: [], images: {} }), + ) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Project uses project-app; the selected credentials belong to my-app. Nothing uploaded.', + ) + }) + + test('checkpoints earlier multi-file uploads if a later upload fails', async () => { + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockResolvedValueOnce({ ...receipt, path: 'website/a.jpg' }) + .mockRejectedValueOnce(new Error('Second upload failed')) + await main(['storage', 'store', './a.jpg', './b.jpg', 'website/']) + expect(process.exitCode).toBe(1) + expect(store).toHaveBeenCalledTimes(2) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + expect(Object.keys(catalog.images)).toEqual(['website/a.jpg']) + }) + + test('refuses duplicate destination basenames before the first upload', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './a/hero.jpg', './b/hero.jpg', 'website/']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + }) + test('stores multiple originals in a directory and commits both receipts', async () => { + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockImplementation(async (_file, options) => ({ ...receipt, path: options.path })) + await main(['storage', 'store', './a.jpg', './b.jpg', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(store.mock.calls).toEqual([ + [ + './a.jpg', + { path: 'website/a.jpg', signal: expect.any(AbortSignal), onReceipt: expect.any(Function) }, + ], + [ + './b.jpg', + { path: 'website/b.jpg', signal: expect.any(AbortSignal), onReceipt: expect.any(Function) }, + ], + ]) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + expect(catalog.workspace).toBe('my-app') + expect(Object.keys(catalog.images)).toEqual(['website/a.jpg', 'website/b.jpg']) + }) + + test('refuses ambiguous multi-file destinations before uploading', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './a.jpg', './b.jpg', 'website/hero.jpg']) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('directory')) + }) + + test('the printed typed path has a readable filename alt', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + const text = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(text).toContain('alt="hero"') + expect(text).not.toContain('Describe this image') + }) + test.each([ + { source: 'shell environment', setup: 'shell' }, + { source: 'project .env', setup: 'project' }, + { source: 'saved login', setup: 'saved' }, + { source: 'shell environment + project .env', setup: 'mixed' }, + ])('discloses the winning $source credentials before uploading, without secrets', async ({ + source, + setup, + }) => { + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=saved-key\nTRANSLOADIT_SECRET=saved-secret\nTRANSLOADIT_WORKSPACE=saved-workspace\n', + ) + if (setup !== 'shell') { + vi.stubEnv('TRANSLOADIT_KEY', setup === 'mixed' ? 'assembly-key' : '') + vi.stubEnv('TRANSLOADIT_SECRET', '') + } + if (setup === 'project' || setup === 'mixed') + await writeFile( + '.env', + 'TRANSLOADIT_KEY=project-key\nTRANSLOADIT_SECRET=project-secret\nTRANSLOADIT_WORKSPACE=project-workspace\n', + ) + vi.spyOn(Transloadit.prototype, 'storeImage').mockImplementation(() => { + if (setup === 'saved') expect(OutputCtl.prototype.notice).not.toHaveBeenCalled() + else + expect(OutputCtl.prototype.notice).toHaveBeenCalledWith( + expect.stringContaining(`Credentials: ${source}`), + ) + return Promise.resolve(receipt) + }) + await runStore() + expect(process.exitCode).toBeUndefined() + const notice = JSON.stringify(vi.mocked(OutputCtl.prototype.notice).mock.calls) + expect(notice).not.toMatch( + /assembly-key|assembly-secret|saved-key|saved-secret|project-key|project-secret/, + ) + if (setup !== 'saved') + expect(notice).toContain(setup === 'shell' ? 'workspace not declared' : 'project-workspace') + }) + test.each([ + '--private', + ])('refuses the removed snippet-only flag %s before uploading', async (delivery) => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', receipt.path, delivery]) + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + await expect(stat('images.json')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + test('stores a root object without implicitly generating workspace-wide authorization', async () => { + const rootReceipt = { ...receipt, path: 'hero.jpg' } + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(rootReceipt) + await runStore(rootReceipt.path) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images['hero.jpg']).toEqual( + rootReceipt, + ) + const snippet = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(snippet).toContain('Render it with { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue({ ...receipt, width }) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining( + ``, + ), + { ...receipt, width }, + ) + }) + + test('printed JSX preserves special characters in Storage paths', async () => { + const path = 'website/a&"b.jpg' + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue({ ...receipt, path }) + await runStore(path) + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('src="website/a&"b.jpg"'), + { ...receipt, path }, + ) + }) + + test('releases the writer lock even when temporary-file cleanup fails', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + const remove = vi.mocked(rm).getMockImplementation() + if (remove === undefined) throw new Error('Expected the real filesystem mock implementation') + vi.mocked(rm).mockImplementation((path, options) => { + if (typeof path === 'string' && path.endsWith('.tmp')) + throw new Error('EPERM: cleanup denied') + return remove(path, options) + }) + await runStore() + expect(process.exitCode).toBe(1) + expect(await readdir(directory)).not.toContain('images.json.lock') + expect(JSON.parse(await readFile('images.json', 'utf8')).images[receipt.path]).toEqual(receipt) + }) + + test('retains the complete receipt and previous catalog when atomic replacement fails', async () => { + const previous = catalogJson({ 'website/earlier.jpg': receipt }) + await writeFile('images.json', previous) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + vi.mocked(rename).mockRejectedValueOnce( + Object.assign(new Error('EACCES: rename denied'), { code: 'EACCES' }), + ) + await runStore() + expect(process.exitCode).toBe(1) + expect(await readFile('images.json', 'utf8')).toBe(previous) + const temporary = (await readdir(directory)).find((name) => name.endsWith('.tmp')) + expect(temporary).toBeDefined() + if (temporary === undefined) throw new Error('Expected retained verified receipt') + expect(JSON.parse(await readFile(temporary, 'utf8')).images[receipt.path]).toEqual(receipt) + const error = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(error).toContain(temporary) + expect(error).toContain('Do not re-upload') + expect(error).toContain('EACCES') + expect(await readdir(directory)).not.toContain('images.json.lock') + }) + + test('preserves an existing catalog mode across its atomic replacement', async () => { + await writeFile('images.json', catalogJson({})) + await chmod('images.json', 0o640) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + expect((await stat('images.json')).mode & 0o777).toBe(0o640) + }) + + test('prints only the changed receipt for a src/app consumer', async () => { + await mkdir('src/app', { recursive: true }) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + const snippet = vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0] + expect(snippet).toContain('Saved website/hero.jpg in images.json.') + expect(snippet).not.toContain('import ') + }) + + test.each([ + 'images.json', + '.images.json', + ])('stores a catalog named %s without printing an import', async (name) => { + await mkdir('app') + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main(['storage', 'store', './hero.jpg', receipt.path, '--receipts', `app/${name}`]) + expect(process.exitCode).toBeUndefined() + expect(vi.mocked(OutputCtl.prototype.print).mock.calls[0]?.[0]).toContain( + `Saved website/hero.jpg in app/${name}.`, + ) + }) + + test('overwrites only when explicitly requested', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await main([ + 'storage', + 'store', + './hero.jpg', + receipt.path, + '--receipts', + 'images.json', + '--overwrite', + ]) + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledExactlyOnceWith('./hero.jpg', { + path: receipt.path, + signal: expect.any(AbortSignal), + onReceipt: expect.any(Function), + overwrite: true, + }) + }) + test('prints recovery details for a malformed receipt after the write and preserves saved receipts', async () => { + const bytes = Buffer.from('image') + await writeFile('hero.jpg', bytes) + const previous = catalogJson({ 'website/earlier.jpg': receipt }) + await writeFile('images.json', previous) + const assemblyId = 'assembly-missing-metadata' + const create = vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + assembly_id: assemblyId, + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [ + { + ...receipt, + md5hash: createHash('md5').update(bytes).digest('hex'), + size: bytes.length, + }, + ], + }, + }) + await runStore() + expect(create).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(1) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(receipt.path) + expect(message).toContain(assemblyId) + expect(message).toContain('transloadit storage ls website/hero.jpg --receipts images.json') + expect(message).toContain( + 'The commands below require the Storage read API, not yet enabled in production', + ) + expect(message).toContain('inspect the Assembly in Console') + expect(message).toContain( + 'transloadit storage receipts sync website/hero.jpg --receipts images.json', + ) + expect(message).toContain('Do not re-upload') + expect(message).not.toMatch(/may already exist|overwrite|conflict_strategy/) + expect(message).not.toContain('assembly-secret') + expect(OutputCtl.prototype.debug).toHaveBeenCalledWith( + expect.stringContaining('"metadataValid":false'), + ) + expect(OutputCtl.prototype.debug).toHaveBeenCalledWith( + expect.stringContaining('"originalCount":1'), + ) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'hero.jpg', 'images.json']) + }) + + test('recovery advice keeps endpoint, workspace and catalog overrides, even for root objects', async () => { + const endpoint = 'http://127.0.0.1:32189' + nock(endpoint) + .get('/storage/') + .query(true) + .reply( + 200, + 'my-app', + ) + await writeFile('hero.jpg', Buffer.from('image')) + vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'missing-original', + results: {}, + }) + await main([ + 'storage', + 'store', + './hero.jpg', + 'hero.jpg', + '--endpoint', + endpoint, + '--workspace', + 'my-app', + '--receipts', + 'custom.json', + ]) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + const options = "--receipts custom.json --endpoint 'http://127.0.0.1:32189' --workspace my-app" + expect(message).toContain(`transloadit storage ls hero.jpg ${options}`) + expect(message).toContain(`transloadit storage receipts sync hero.jpg ${options}`) + expect(message).not.toContain("sync ''") + }) + + test.each([ + 'ASSEMBLY_CANCELED', + 'ASSEMBLY_EXECUTING', + ] as const)('does not claim a stored object or metadata recovery for %s', async (ok) => { + await writeFile('hero.jpg', Buffer.from('image')) + vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + ok, + assembly_id: 'not-completed', + results: {}, + }) + await runStore() + expect(process.exitCode).toBe(1) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(ok) + expect(message).toContain('not-completed') + expect(message).not.toMatch(/storage ls|receipts sync|Do not re-upload|already exist/) + }) + + test('keeps a receipts-file permission error and names the file before uploading', async () => { + const previous = catalogJson({ [receipt.path]: receipt }) + await writeFile('images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + // Inject the OS error so this also exercises EACCES when the test process runs as root. + vi.mocked(readFile).mockRejectedValueOnce( + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }), + ) + await runStore() + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(join(directory, 'images.json')) + expect(message).toContain('EACCES: permission denied') + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) + }) + + test.each([ + 'symlink', + 'directory', + ])('names a receipts %s without calling it invalid JSON', async (kind) => { + if (kind === 'symlink') await symlink('credentials', 'images.json') + else await mkdir('images.json') + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(join(directory, 'images.json')) + expect(message).toContain('regular JSON file') + expect(message).toContain(kind) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) + }) + + test('preserves every existing path, including ordinary JSON prototype-looking keys', async () => { + const earlier = { ...receipt, path: '__proto__' } + await writeFile('images.json', catalogJson({ [earlier.path]: earlier })) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual({ + [earlier.path]: earlier, + [receipt.path]: receipt, + }) + }) + + test('uses storeImage and appends a keyed receipt with a ready-to-render snippet', async () => { + const earlier = { ...receipt, path: 'website/earlier.jpg' } + await writeFile('images.json', catalogJson({ [earlier.path]: earlier })) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledExactlyOnceWith('./hero.jpg', { + path: receipt.path, + signal: expect.any(AbortSignal), + onReceipt: expect.any(Function), + }) + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual({ + [earlier.path]: earlier, + [receipt.path]: receipt, + }) + expect(await readFile('images.json', 'utf8')).toMatch(/\n$/) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('\nReplace alt with a description (or an empty string for a decorative image).', + ) + expect(await readdir(directory)).toEqual([ + 'credentials', + 'images.json', + 'transloadit-images.d.ts', + ]) + }) + + test('preserves the first receipt when a second store conflicts', async () => { + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValueOnce(receipt) + await runStore() + const previous = await readFile('images.json', 'utf8') + store.mockRejectedValueOnce(new ApiError({ body: { error: 'TRANSLOADIT_STORE_CONFLICT' } })) + await runStore() + expect(process.exitCode).toBe(1) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual([ + 'credentials', + 'images.json', + 'transloadit-images.d.ts', + ]) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + 'Storage destination "website/hero.jpg" already exists. Choose a fresh name; use --overwrite only if you deliberately want to replace that object.', + ) + }) + + test('leaves no receipts or temporary files after an upload failure', async () => { + const store = vi + .spyOn(Transloadit.prototype, 'storeImage') + .mockRejectedValue(new Error('Offline')) + await runStore() + expect(store).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledExactlyOnceWith('Offline') + expect(await readdir(directory)).toEqual(['credentials']) + }) + + test.each([ + '', + 'null', + '[]', + '{', + ])('rejects invalid existing receipts before uploading (%j)', async (previous) => { + await writeFile('images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('images.json', 'utf8')).toBe(previous) + const message = vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join('\n') + expect(message).toContain(join(directory, 'images.json')) + expect(message).toContain( + previous === '' || previous === '{' ? 'invalid JSON' : 'expected a project catalog', + ) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) + }) + + test('refuses another writer before uploading instead of losing its receipts', async () => { + await writeFile('images.json.lock', 'another-writer') + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('another storage store'), + ) + expect(await readFile('images.json.lock', 'utf8')).toBe('another-writer') + }) + + test('uses the existing CLI credentials-file resolution without Smart CDN fallback', async () => { + vi.stubEnv('TRANSLOADIT_KEY', '') + vi.stubEnv('TRANSLOADIT_SECRET', '') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'delivery-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'delivery-secret') + await writeFile('credentials', 'TRANSLOADIT_KEY=stored-key\nTRANSLOADIT_SECRET=stored-secret\n') + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(store).toHaveBeenCalledOnce() + }) + + test('missing write credentials preserves receipts and never uploads', async () => { + vi.stubEnv('TRANSLOADIT_KEY', '') + vi.stubEnv('TRANSLOADIT_SECRET', '') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'delivery-key') + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'delivery-secret') + const previous = catalogJson({ [receipt.path]: receipt }) + await writeFile('images.json', previous) + const store = vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue(receipt) + await runStore() + expect(process.exitCode).toBe(1) + expect(store).not.toHaveBeenCalled() + expect(await readFile('images.json', 'utf8')).toBe(previous) + }) +}) + +function catalogJson(images: Record): string { + return `${JSON.stringify({ workspace: 'my-app', public: [], images })}\n` +} diff --git a/packages/node/test/unit/lint-assembly.test.ts b/packages/node/test/unit/lint-assembly.test.ts index 23d2906b..9e43b65d 100644 --- a/packages/node/test/unit/lint-assembly.test.ts +++ b/packages/node/test/unit/lint-assembly.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest' import { lintAssemblyInstructions } from '../../src/lintAssemblyInstructions.ts' describe('lintAssemblyInstructions', () => { + it('accepts recursive Storage folder imports from the canonical Robot schema', async () => { + const result = await lintAssemblyInstructions({ + assemblyInstructions: { + steps: { + imported: { robot: '/transloadit/import', path: 'photos/', recursive: true }, + stored: { robot: '/transloadit/store', use: 'imported', path: 'copies/${file.url_name}' }, + }, + }, + }) + expect(result.success, JSON.stringify(result.issues)).toBe(true) + expect(result.issues.filter((issue) => issue.type === 'error')).toEqual([]) + }) + it('wraps steps-only input and respects fatal level', async () => { const result = await lintAssemblyInstructions({ assemblyInstructions: {}, diff --git a/packages/node/test/unit/mock-http.test.ts b/packages/node/test/unit/mock-http.test.ts index 8e76dd4c..195dae5f 100644 --- a/packages/node/test/unit/mock-http.test.ts +++ b/packages/node/test/unit/mock-http.test.ts @@ -139,6 +139,36 @@ describe('Mocked API tests', () => { scope.done() }) + it('validates a Storage conflict when polling and preserves its code in createAssembly', async () => { + const client = getLocalClient({ validateResponses: true }) + const conflict = { + error: 'TRANSLOADIT_STORE_CONFLICT', + assembly_id: 'storage-conflict', + assembly_url: 'http://localhost/assemblies/storage-conflict', + assembly_ssl_url: 'http://localhost/assemblies/storage-conflict', + message: 'An asset already exists at this path', + } + const scope = nock('http://localhost') + .get('/assemblies/storage-conflict') + .query(true) + .reply(200, conflict) + .post(createAssemblyRegex) + .reply(200, { ok: 'ASSEMBLY_EXECUTING', assembly_id: 'storage-conflict' }) + .get('/assemblies/storage-conflict') + .query(true) + .reply(200, conflict) + + await expect(client.awaitAssemblyCompletion('storage-conflict')).resolves.toMatchObject( + conflict, + ) + await expect(client.createAssembly({ waitForCompletion: true })).rejects.toMatchObject({ + name: 'ApiError', + code: 'TRANSLOADIT_STORE_CONFLICT', + assemblyId: 'storage-conflict', + }) + scope.done() + }) + it('should return error when GETting a failed assembly', async () => { const client = getLocalClient() diff --git a/packages/node/test/unit/robots.test.ts b/packages/node/test/unit/robots.test.ts index 27936e6c..82673660 100644 --- a/packages/node/test/unit/robots.test.ts +++ b/packages/node/test/unit/robots.test.ts @@ -3,6 +3,25 @@ import { describe, expect, it } from 'vitest' import { getRobotHelp, listRobots } from '../../src/Transloadit.ts' describe('robot catalog helpers', () => { + it.each([ + '/transloadit/store', + '/transloadit/import', + ])('documents the Storage robot %s offline', (robotName) => { + const help = getRobotHelp({ robotName, detailLevel: 'full' }) + expect(help.name).toBe(robotName) + expect([...help.requiredParams, ...help.optionalParams].map((param) => param.name)).toContain( + 'path', + ) + expect(help.examples?.length).toBeGreaterThan(0) + }) + + it('explains a complete destination path for Storage exports', () => { + const help = getRobotHelp({ robotName: '/transloadit/store', detailLevel: 'full' }) + const path = help.optionalParams.find((param) => param.name === 'path') + expect(path?.description).toContain('folders and a filename') + expect(path?.description).toContain('website/hero.jpg') + expect(help.optionalParams.find((param) => param.name === 'conflict_strategy')).toBeDefined() + }) it('lists robots with searchable summaries', () => { const { robots, nextCursor } = listRobots({ search: 'image', limit: 3 }) diff --git a/packages/node/test/unit/storageImage.test.ts b/packages/node/test/unit/storageImage.test.ts new file mode 100644 index 00000000..b2426438 --- /dev/null +++ b/packages/node/test/unit/storageImage.test.ts @@ -0,0 +1,572 @@ +import type { AssemblyStatus } from '../../src/Transloadit.ts' + +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import nock from 'nock' +import sharp from 'sharp' +import { rgbaToThumbHash, thumbHashToRGBA } from 'thumbhash' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' + +import { ApiError, InconsistentResponseError, Transloadit } from '../../src/Transloadit.ts' + +const filePath = resolve(import.meta.dirname, '../e2e/fixtures/sample.jpg') +const bytes = await readFile(filePath) +const receipt = { + asset_id: 'JN6OawlqFmL419U23jUKcg', + md5hash: createHash('md5').update(bytes).digest('hex'), + meta: { height: 100, width: 100 }, + path: 'website/photo.jpg', + size: bytes.length, +} +const completed: AssemblyStatus = { + assembly_id: 'completed-assembly', + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [receipt] }, +} + +function fixture(response: AssemblyStatus = completed) { + const client = new Transloadit({ + authKey: 'test-key', + authSecret: 'test-secret', + endpoint: 'http://127.0.0.1:9', + }) + const create = vi + .spyOn(client, 'createAssembly') + .mockImplementation(() => + Object.assign(Promise.resolve(response), { assemblyId: 'completed-assembly' }), + ) + return { client, create } +} + +afterEach(() => { + vi.restoreAllMocks() + nock.cleanAll() +}) + +test.each([ + 'TRANSLOADIT_STORE_CONFLICT', + 'TRANSLOADIT_STORE_UNAVAILABLE', +] as const)('preserves %s when recovering a failed Assembly status', async (error) => { + const failed = { + assembly_id: completed.assembly_id, + assembly_ssl_url: 'https://api2.transloadit.com/assemblies/completed-assembly', + assembly_url: 'http://api2.transloadit.com/assemblies/completed-assembly', + error, + message: 'Storage write failed', + reason: 'The requested destination could not be written', + } satisfies AssemblyStatus + const { client, create } = fixture() + const api = nock('http://127.0.0.1:9') + .get('/assemblies/completed-assembly') + .query(true) + .reply(200, failed) + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + }), + ).rejects.toMatchObject({ + name: 'ApiError', + code: error, + assemblyId: completed.assembly_id, + assemblySslUrl: failed.assembly_ssl_url, + rawMessage: failed.message, + reason: failed.reason, + }) + expect(create).not.toHaveBeenCalled() + expect(api.isDone()).toBe(true) +}) + +test.each([ + 'ASSEMBLY_UPLOADING', + 'ASSEMBLY_EXECUTING', + 'ASSEMBLY_REPLAYING', +] as const)('distinguishes %s from a malformed completed receipt', async (ok) => { + const { client } = fixture() + vi.spyOn(client, 'getAssembly').mockResolvedValue({ ...completed, ok, results: {} }) + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + }), + ).rejects.toMatchObject({ + name: 'InconsistentResponseError', + message: `The Storage Assembly is not complete (${ok})`, + cause: { assemblyId: completed.assembly_id }, + }) +}) + +test('reports canceled Assemblies distinctly when storing or recovering receipts', async () => { + const canceled = { ...completed, ok: 'ASSEMBLY_CANCELED', results: {} } satisfies AssemblyStatus + const { client } = fixture(canceled) + vi.spyOn(client, 'getAssembly').mockResolvedValue(canceled) + const expected = { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash } + await expect(client.storeImage(filePath, { path: receipt.path })).rejects.toMatchObject({ + message: expect.stringContaining('ASSEMBLY_CANCELED'), + cause: { assemblyId: completed.assembly_id }, + }) + await expect( + client.getStoredImageReceipt({ assemblyId: 'completed-assembly', expected }), + ).rejects.toMatchObject({ + message: expect.stringContaining('ASSEMBLY_CANCELED'), + cause: { assemblyId: completed.assembly_id }, + }) +}) + +test('retrieves a verified receipt from a completed Assembly without uploading again', async () => { + const { client, create } = fixture() + const get = vi.spyOn(client, 'getAssembly').mockResolvedValue(completed) + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + }), + ).resolves.toEqual({ + asset_id: receipt.asset_id, + path: receipt.path, + size: receipt.size, + md5hash: receipt.md5hash, + width: 100, + height: 100, + }) + expect(get).toHaveBeenCalledExactlyOnceWith('completed-assembly') + expect(create).not.toHaveBeenCalled() +}) + +test('explicit overwrite changes only the Storage conflict policy', async () => { + const { client, create } = fixture() + await client.storeImage(filePath, { path: receipt.path, overwrite: true }) + expect(create.mock.calls[0]?.[0]?.params?.steps).toEqual({ + stored: { + robot: '/transloadit/store', + use: ':original', + path: receipt.path, + conflict_strategy: 'overwrite', + }, + }) +}) + +test.each(['path', 'size', 'md5hash'])('recovery rejects an unexpected %s', async (field) => { + const { client } = fixture() + vi.spyOn(client, 'getAssembly').mockResolvedValue(completed) + const expected = { + path: receipt.path, + size: receipt.size, + md5hash: receipt.md5hash, + [field]: field === 'size' ? 1 : field === 'path' ? 'other.jpg' : '0'.repeat(32), + } + await expect( + client.getStoredImageReceipt({ assemblyId: 'completed-assembly', expected }), + ).rejects.toMatchObject({ + name: 'InconsistentResponseError', + cause: { assemblyId: 'completed-assembly' }, + }) +}) + +test('recovery refuses malformed expectations before fetching', async () => { + const { client } = fixture() + const get = vi.spyOn(client, 'getAssembly') + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: '../escape', size: -1, md5hash: 'invalid' }, + }), + ).rejects.toThrow() + expect(get).not.toHaveBeenCalled() +}) + +test('stores one original at the exact destination and returns only the verified image receipt', async () => { + const { client, create } = fixture() + const result = await client.storeImage(filePath, { path: receipt.path }) + expect(result).toEqual({ + asset_id: receipt.asset_id, + height: 100, + md5hash: receipt.md5hash, + path: receipt.path, + size: bytes.length, + width: 100, + thumbhash: expect.any(String), + }) + expect(create).toHaveBeenCalledExactlyOnceWith({ + files: { image: filePath }, + params: { + steps: { + stored: { + robot: '/transloadit/store', + use: ':original', + path: receipt.path, + conflict_strategy: 'error', + }, + }, + }, + waitForCompletion: true, + }) +}) + +test('encodes a small, oriented ThumbHash from the same original bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'storage-thumbhash-')) + onTestFinished(() => rm(directory, { recursive: true, force: true })) + const path = join(directory, 'oriented.jpg') + const image = await sharp({ + create: { width: 160, height: 80, channels: 3, background: '#3172aa' }, + }) + .withMetadata({ orientation: 6 }) + .jpeg() + .toBuffer() + await writeFile(path, image) + const stored = { + ...receipt, + size: image.length, + md5hash: createHash('md5').update(image).digest('hex'), + meta: { width: 160, height: 80, orientation: 6 }, + } + const { client } = fixture({ ...completed, results: { ':original': [stored] } }) + const result = await client.storeImage(path, { path: receipt.path }) + const { data, info } = await sharp(image) + .autoOrient() + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + expect(result).toMatchObject({ + width: 80, + height: 160, + thumbhash: Buffer.from(rgbaToThumbHash(info.width, info.height, data)).toString('base64'), + }) + const hash = result.thumbhash + expect(typeof hash).toBe('string') + if (typeof hash !== 'string') throw new Error('Missing ThumbHash') + const decoded = thumbHashToRGBA(Buffer.from(hash, 'base64')) + expect(decoded.w).toBeLessThan(decoded.h) + expect(result).not.toHaveProperty('hasAlpha') +}) + +test.each([ + true, + false, +])('alpha metadata describes the verified stored bytes (unchanged: %s)', async (unchanged) => { + const directory = await mkdtemp(join(tmpdir(), 'storage-alpha-')) + onTestFinished(() => rm(directory, { recursive: true, force: true })) + const path = join(directory, 'transparent.png') + const image = await sharp({ + create: { + width: 16, + height: 16, + channels: 4, + background: { r: 45, g: 110, b: 160, alpha: 0.5 }, + }, + }) + .png() + .toBuffer() + await writeFile(path, image) + const stored = { + ...receipt, + size: image.length, + md5hash: unchanged ? createHash('md5').update(image).digest('hex') : 'a'.repeat(32), + meta: { width: 16, height: 16 }, + } + const { client } = fixture({ ...completed, results: { ':original': [stored] } }) + const onReceipt = vi.fn() + const result = await client.storeImage(path, { path: receipt.path, onReceipt }) + if (unchanged) { + expect(result).toMatchObject({ hasAlpha: true, thumbhash: expect.any(String) }) + expect(onReceipt).toHaveBeenCalledWith(result, expect.anything(), completed.assembly_id) + } else { + expect(result).not.toHaveProperty('hasAlpha') + expect(result).not.toHaveProperty('thumbhash') + } +}) + +test('a locally unsupported decoder does not prevent storing a verified original', async () => { + const directory = await mkdtemp(join(tmpdir(), 'storage-thumbhash-')) + onTestFinished(() => rm(directory, { recursive: true, force: true })) + const path = join(directory, 'original.raw') + const image = Buffer.from('format only the remote image decoder understands') + await writeFile(path, image) + const stored = { + ...receipt, + size: image.length, + md5hash: createHash('md5').update(image).digest('hex'), + } + const { client } = fixture({ ...completed, results: { ':original': [stored] } }) + const result = await client.storeImage(path, { path: receipt.path }) + expect(result.md5hash).toBe(stored.md5hash) + expect(result).not.toHaveProperty('thumbhash') +}) + +test('a missing optional Sharp installation does not prevent storing an original', async () => { + vi.doMock('sharp', () => { + throw new Error('Optional decoder unavailable') + }) + try { + const { client } = fixture() + const result = await client.storeImage(filePath, { path: receipt.path }) + expect(result.md5hash).toBe(receipt.md5hash) + expect(result).not.toHaveProperty('thumbhash') + expect(result).not.toHaveProperty('hasAlpha') + } finally { + vi.doUnmock('sharp') + } +}) + +test.each([ + { size: 71_336, md5hash: 'b'.repeat(32) }, + { size: bytes.length, md5hash: 'c'.repeat(32) }, +])('returns stored metadata when the workspace transforms the upload: %j', async (stored) => { + const { client } = fixture({ + ...completed, + results: { ':original': [{ ...receipt, ...stored, meta: { width: 1200, height: 800 } }] }, + }) + await expect(client.storeImage(filePath, { path: receipt.path })).resolves.toEqual({ + asset_id: receipt.asset_id, + path: receipt.path, + ...stored, + width: 1200, + height: 800, + }) +}) + +test('reports input and stored receipt once, without letting an observer hide a completed write', async () => { + const { client } = fixture() + const onReceipt = vi.fn(() => { + throw new Error('observer failed') + }) + await expect( + client.storeImage(filePath, { path: receipt.path, onReceipt }), + ).resolves.toMatchObject({ + path: receipt.path, + md5hash: receipt.md5hash, + }) + expect(onReceipt).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ asset_id: receipt.asset_id }), + { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + 'completed-assembly', + ) +}) + +test('handles an async receipt observer rejection after returning a verified receipt', async () => { + const { client } = fixture() + const unhandled: unknown[] = [] + const observeUnhandled = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', observeUnhandled) + try { + let calls = 0 + function onReceipt(): Promise { + calls += 1 + return Promise.reject(new Error('async observer failed')) + } + await expect( + client.storeImage(filePath, { path: receipt.path, onReceipt }), + ).resolves.toMatchObject({ + asset_id: receipt.asset_id, + }) + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + expect(calls).toBe(1) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', observeUnhandled) + } +}) + +test.each<[string | number | null | undefined, number, number]>([ + [undefined, 450, 600], + [null, 450, 600], + ['Horizontal (normal)', 450, 600], + ['Mirror horizontal', 450, 600], + ['Rotate 180', 450, 600], + ['Mirror vertical', 450, 600], + ['Mirror horizontal and rotate 270 CW', 600, 450], + ['Rotate 90 CW', 600, 450], + ['Mirror horizontal and rotate 90 CW', 600, 450], + ['Rotate 270 CW', 600, 450], + [1, 450, 600], + [2, 450, 600], + [3, 450, 600], + [4, 450, 600], + [5, 600, 450], + [6, 600, 450], + [7, 600, 450], + [8, 600, 450], +])('returns display dimensions for EXIF orientation %j', async (orientation, width, height) => { + // API2's file-info/rotated_8.jpg.json reports 450×600 with "Rotate 90 CW". + const meta = { width: 450, height: 600, orientation } + const { client } = fixture({ + ...completed, + results: { ':original': [{ ...receipt, meta }] }, + }) + await expect(client.storeImage(filePath, { path: receipt.path })).resolves.toMatchObject({ + width, + height, + }) + expect(meta).toEqual({ width: 450, height: 600, orientation }) + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + ...completed, + results: { ':original': [{ ...receipt, meta }] }, + }) + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + }), + ).resolves.toMatchObject({ width, height }) +}) + +test.each<[string, AssemblyStatus]>([ + ['missing results', { ...completed, results: undefined }], + ['missing original', { ...completed, results: { stored: [receipt] } }], + ['empty original', { ...completed, results: { ':original': [] } }], + ['multiple originals', { ...completed, results: { ':original': [receipt, receipt] } }], + ['incomplete Assembly', { ...completed, ok: 'ASSEMBLY_EXECUTING' }], +])('rejects %s while preserving the Assembly identifier for investigation', async (_name, response) => { + const { client } = fixture(response) + await expect(client.storeImage(filePath, { path: receipt.path })).rejects.toMatchObject({ + name: 'InconsistentResponseError', + cause: { assemblyId: 'completed-assembly' }, + }) +}) + +test.each([ + ['missing asset ID', { ...receipt, asset_id: undefined }], + ['empty asset ID', { ...receipt, asset_id: '' }], + ['whitespace asset ID', { ...receipt, asset_id: ' ' }], + ['wrong path', { ...receipt, path: 'website/other.jpg' }], + ['zero byte count', { ...receipt, size: 0 }], + ['missing checksum', { ...receipt, md5hash: undefined }], + ['malformed checksum', { ...receipt, md5hash: 'not-an-md5' }], + ['missing metadata', { ...receipt, meta: undefined }], + ['missing width', { ...receipt, meta: { height: 100 } }], + ['zero height', { ...receipt, meta: { width: 100, height: 0 } }], + ['negative width', { ...receipt, meta: { width: -1, height: 100 } }], + ['fractional height', { ...receipt, meta: { width: 100, height: 1.5 } }], + ['unsafe width', { ...receipt, meta: { width: Number.MAX_SAFE_INTEGER + 1, height: 100 } }], + ['non-finite height', { ...receipt, meta: { width: 100, height: Number.POSITIVE_INFINITY } }], +])('rejects a receipt with %s after writing', async (_name, invalid) => { + const { client, create } = fixture({ ...completed, results: { ':original': [invalid] } }) + await expect(client.storeImage(filePath, { path: receipt.path })).rejects.toThrow( + InconsistentResponseError, + ) + expect(create).toHaveBeenCalledOnce() + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + ...completed, + results: { ':original': [invalid] }, + }) + await expect( + client.getStoredImageReceipt({ + assemblyId: 'completed-assembly', + expected: { path: receipt.path, size: receipt.size, md5hash: receipt.md5hash }, + }), + ).rejects.toThrow(InconsistentResponseError) +}) + +test.each([ + '', + 'website/', + '/website/photo.jpg', + 'website//photo.jpg', + '../photo.jpg', + 'website/${file.url_name}', + ' website/photo.jpg', +])('rejects incomplete or ambiguous destination %j before upload', async (path) => { + const { client, create } = fixture() + await expect(client.storeImage(filePath, { path })).rejects.toThrow(/path/) + expect(create).not.toHaveBeenCalled() +}) + +test('preserves upload/polling options without permitting replacement instructions', async () => { + const { client, create } = fixture() + const controller = new AbortController() + const onUploadProgress = vi.fn() + const onAssemblyProgress = vi.fn() + const options = { + path: receipt.path, + chunkSize: 64 * 1024, + onUploadProgress, + onAssemblyProgress, + signal: controller.signal, + timeout: 12_345, + params: { steps: { unsafe: { robot: '/http/import', url: 'https://example.invalid/' } } }, + waitForCompletion: false, + } + const pending = client.storeImage(filePath, options) + options.path = 'website/changed.jpg' + await pending + const sent = create.mock.calls[0]?.[0] + expect(sent).toMatchObject({ + chunkSize: 64 * 1024, + onUploadProgress, + onAssemblyProgress, + signal: controller.signal, + timeout: 12_345, + waitForCompletion: true, + }) + expect(Object.keys(sent?.params?.steps ?? {})).toEqual(['stored']) + sent?.onUploadProgress?.({ totalBytes: bytes.length, uploadedBytes: bytes.length }) + sent?.onAssemblyProgress?.(completed) + expect(onUploadProgress).toHaveBeenCalledOnce() + expect(onAssemblyProgress).toHaveBeenCalledWith(completed) +}) + +test('preserves conflict and timeout errors from the existing Assembly client', async () => { + const { client, create } = fixture() + const conflict = new ApiError({ + body: { error: 'TRANSLOADIT_STORE_CONFLICT', assembly_id: 'conflict' }, + }) + create.mockImplementationOnce(() => { + throw conflict + }) + await expect(client.storeImage(filePath, { path: receipt.path })).rejects.toBe(conflict) + const timeout = new Error('Existing SDK timeout') + create.mockImplementationOnce(() => { + throw timeout + }) + await expect(client.storeImage(filePath, { path: receipt.path })).rejects.toBe(timeout) +}) + +test.each([ + 'before', + 'during', +])('honors cancellation %s checksum reading without starting an Assembly', async (when) => { + const { client, create } = fixture() + const controller = new AbortController() + if (when === 'before') controller.abort() + const pending = client.storeImage(filePath, { path: receipt.path, signal: controller.signal }) + if (when === 'during') controller.abort() + await expect(pending).rejects.toHaveProperty('name', 'AbortError') + expect(create).not.toHaveBeenCalled() +}) + +test('rejects empty and missing files before starting an Assembly', async () => { + const { client, create } = fixture() + await expect( + client.storeImage(resolve(import.meta.dirname, '../e2e/fixtures/zerobytes.jpg'), { + path: receipt.path, + }), + ).rejects.toThrow(/empty/) + await expect( + client.storeImage(`${filePath}.missing`, { path: receipt.path }), + ).rejects.toMatchObject({ code: 'ENOENT' }) + expect(create).not.toHaveBeenCalled() +}) + +test('checksums input larger than a stream chunk without truncating the byte count', async () => { + const directory = await mkdtemp(join(tmpdir(), 'store-image-unit-')) + onTestFinished(() => rm(directory, { recursive: true, force: true })) + const large = Buffer.alloc(256 * 1024, 42) + const path = join(directory, 'large.jpg') + await writeFile(path, large) + const expected = createHash('md5').update(large).digest('hex') + const { client } = fixture({ + ...completed, + results: { ':original': [{ ...receipt, size: large.length, md5hash: expected }] }, + }) + await expect(client.storeImage(path, { path: receipt.path })).resolves.toMatchObject({ + md5hash: expected, + size: large.length, + }) +}) diff --git a/packages/transloadit/README.md b/packages/transloadit/README.md index 0daaa0aa..a7f06fc3 100644 --- a/packages/transloadit/README.md +++ b/packages/transloadit/README.md @@ -24,7 +24,7 @@ This is a **Node.js** SDK to make it easy to talk to the ## Requirements -- [Node.js](https://nodejs.org/en/) version 20 or newer +- [Node.js](https://nodejs.org/en/) version 20.10.0 or newer - [A Transloadit account](https://transloadit.com/signup/) ([free signup](https://transloadit.com/pricing/)) - [Your API credentials](https://transloadit.com/c/template-credentials) (`authKey`, `authSecret`) @@ -107,6 +107,72 @@ Most commands can authenticate with either `TRANSLOADIT_AUTH_TOKEN` or `TRANSLOA `TRANSLOADIT_SECRET`. Commands that mint bearer tokens or generate signatures still require `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET`. +### Storage images for Next.js + +`@transloadit/img` is currently an unpublished, private preview. Follow the +[local package instructions](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md) +until release. In a Next.js 16.3.3+ App Router project with Storage enabled, run: + +```bash +yarn transloadit auth login +yarn transloadit storage store ./hero.jpg website/hero.jpg --public --hashed +yarn dev +``` + +Follow the image package Quickstart to add its Next plugin and import StorageImage from the package. +`--hashed` creates an immutable filename; use the printed JSX path, including its content hash. +Start with `auth login` even without an account: choose Sign up in the browser it opens, then approve +the CLI. For application-server uploads instead of CLI seeding, see [Store an image](#store-an-image). +Login opens browser approval (on Windows, open the printed URL) and saves one combined Auth Key, +workspace and signing algorithm in the owner-only credentials file. `--no-browser` prints the +approval URL; `--stdin` accepts dotenv credentials for automation. Existing credentials require +`--replace`. Login also checks Storage policy access with a bounded read-only request; if that +fails, it saves the login and prints a Console link. This check does not prove upload availability. + +Store creates `transloadit.images.json` and derived `transloadit-images.d.ts`; commit both. The +catalog carries workspace, public prefixes and image receipts: no public app env is needed. +`--public` declares the destination directory recursively, including future uploads, after saving +the receipt. Plain store never publishes. `image init website/ --example` is an optional example +generator; private init (`image init uploads/ --private`) creates `transloadit.authorize.ts` and a +route that denies access until you connect per-object authorization. Use a separate deployment key. +Existing code/env files are never overwritten. Missing trailing directory slashes are accepted. + +Store uploads originals and appends validated receipts to `transloadit.images.json`; commit it. +`storage store ./images/*.jpg website/` stores shell-expanded files, checkpointing each success. +It prints `width={960}` (bounded by the original) and `placeholder="blur"`, with a filename-derived +alt and a reminder. Store generates an optional base64 `thumbhash` from the original bytes. +An occupied path conflicts unless `--overwrite` is explicit; prefer `--hashed` for immutable +filenames. Matching receipts skip repeat uploads; changed bytes get a new name. +The public image `v` is a cache-busting tag derived from the receipt hash; the origin does not verify +it, so a cold request after an overwrite can return the replacement. +Publication can also be managed explicitly: + +```bash +yarn transloadit storage publish website/ +yarn transloadit storage publications +yarn transloadit storage unpublish website/ +yarn transloadit auth status +yarn transloadit auth logout +``` + +Logout revokes browser-login keys, including their use by any application. Imported (`--stdin`) +and legacy keys are only forgotten locally; `auth logout --revoke` explicitly revokes those too. + +Unpublishing stops origin access but cannot recall cached/downloaded bytes. For lost metadata, +restore the committed catalog. Listing and sync need the Storage read API, not yet enabled in +production; see [Recovery options and prerequisites](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#recovery-requires-the-storage-read-api-not-yet-enabled-in-production). + +Storage commands report the selected credential source on stderr before operating. Ordinary +commands retain shell → project `.env` → saved login precedence; init prefers the saved login. +Store, list, sync and publication verify the selected key against the catalog workspace and stop +before acting on a mismatch. An explicit `--workspace` opts out, but never mixes catalogs: use +`--receipts` for a separate workspace's catalog. Status shows the saved workspace/key description; +logout revokes that key before deleting the credentials file and preserves it if revocation fails. +The endpoint stays bound to those credentials unless explicitly overridden. JSON result output +stays on stdout; credentials are never printed. See the +[image Quickstart](https://github.com/transloadit/node-sdk/blob/main/packages/img/README.md) +and [reference](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md). + ### Minting Bearer Tokens (Hosted MCP) If you want to connect an agent to the Transloadit-hosted MCP endpoint, mint a short-lived bearer @@ -1379,7 +1445,48 @@ npx transloadit assemblies list -l 7 ## SDK Usage -The following code will upload an image and resize it to a thumbnail: +### Store an image + +`storeImage()` uploads one local image and returns a verified receipt ready for `StorageImage`. +Install `@transloadit/node` as a runtime dependency for server uploads; a dev dependency is enough +when you only use its CLI. Use an Assembly-enabled application key in your server environment, +not a signing-only image-delivery key or the disposable login key that `auth logout` revokes. +New combined Smart CDN/Assembly keys use SHA-256, as selected below; use your configured algorithm +for an existing key. + +In an authenticated server handler, `user` is your verified session user and `saveImage` is your +application's database helper, not an SDK function: + +```ts +import { randomUUID } from 'node:crypto' +import { Transloadit } from '@transloadit/node' + +const { TRANSLOADIT_KEY: authKey, TRANSLOADIT_SECRET: authSecret } = process.env +if (!authKey || !authSecret) { + throw new Error('Set TRANSLOADIT_KEY and TRANSLOADIT_SECRET on the server') +} +const client = new Transloadit({ + authKey, + authSecret, + signatureAlgorithm: 'sha256', +}) +const receipt = await client.storeImage('./hero.jpg', { + path: `uploads/${randomUUID()}/hero.jpg`, +}) +await saveImage({ ...receipt, ownerId: user.id }) +``` + +Choose the destination on the server and save the complete receipt with its owner ID. An occupied +path is refused unless you explicitly pass `overwrite: true`. This helper does not publish a +directory or update the CLI's catalog. Pass the saved receipt as `src` in an authorized application; +see [user uploads, private access and trusted receipt recovery with `getStoredImageReceipt()`](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#images-uploaded-by-your-users) +for the Uppy/notification flow and recovery without another upload. + +### Process an image + +The following code will upload an image and resize it to a thumbnail. New Console-created combined +Smart CDN/Assembly keys use SHA-256, so this example selects it explicitly; the SDK's legacy default +remains SHA-384 for existing keys. ```javascript import { Transloadit } from '@transloadit/node' @@ -1387,6 +1494,7 @@ import { Transloadit } from '@transloadit/node' const transloadit = new Transloadit({ authKey: 'YOUR_TRANSLOADIT_KEY', authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', }) try { @@ -1486,10 +1594,24 @@ Table of contents: Returns a new instance of the client. +```typescript +const transloadit = new Transloadit({ + authKey: 'YOUR_TRANSLOADIT_KEY', + authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', +}) +``` + +Select SHA-256 for new Console-created combined Smart CDN/Assembly keys; omitting it retains +the SHA-384 default for existing keys and can produce `INVALID_SIGNATURE` with a combined key. + The `options` object can contain the following keys: - `authKey` **(required)** - see [requirements](#requirements) - `authSecret` **(required)** - see [requirements](#requirements) +- `signatureAlgorithm` (default `'sha384'`) - API signing algorithm configured on this Auth Key. + Use `'sha256'` for combined Smart CDN-enabled keys. Browser CLI login saves and applies this + setting automatically; explicit `calcSignature(params, algorithm)` calls still override it. - `endpoint` (default `'https://api2.transloadit.com'`) - `maxRetries` (default `5`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) - `gotRetry` (default `0`) - see [Rate limiting & auto retry](#rate-limiting--auto-retry) @@ -1934,8 +2056,3 @@ Thanks to [Ian Hansen](https://github.com/supershabam) for donating the `translo ## Development See [CONTRIBUTING](./CONTRIBUTING.md). - - - - - diff --git a/packages/transloadit/package.json b/packages/transloadit/package.json index 73262def..bf7541f5 100644 --- a/packages/transloadit/package.json +++ b/packages/transloadit/package.json @@ -29,15 +29,17 @@ "author": "Tim Koschuetzki ", "packageManager": "yarn@4.12.0", "engines": { - "node": ">= 20" + "node": ">= 20.10.0" }, "dependencies": { + "@aws-sdk/client-s3": "3.1073.0", "@transloadit/sev-logger": "^0.1.9", "@transloadit/utils": "^4.8.1", "cacheable-lookup": "^7.0.0", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.3", "dotenv": "^17.4.2", + "execa": "9.6.1", "form-data": "^4.0.5", "got": "14.6.6", "into-stream": "^9.1.0", @@ -48,11 +50,15 @@ "p-map": "^7.0.4", "p-queue": "^9.3.0", "recursive-readdir": "^2.2.3", + "thumbhash": "0.1.1", "tus-js-client": "^4.3.1", "typanion": "^3.14.0", "type-fest": "^5.6.0", "zod": "3.25.76" }, + "optionalDependencies": { + "sharp": "0.35.4" + }, "devDependencies": { "@types/debug": "^4.1.13", "@types/node": "^25.8.0", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index af56d73b..960919ce 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,6 +32,7 @@ export { resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from './smartCdnImage.ts' +export { validateStoragePath, validateStoragePathPrefix } from './storagePath.ts' const algorithmMap = { sha1: 'SHA-1', diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts index 3e48f001..caee9ad6 100644 --- a/packages/utils/src/node.ts +++ b/packages/utils/src/node.ts @@ -1,13 +1,13 @@ import type { SignatureAlgorithm } from './index.ts' -import type { SmartCdnUrlOptions } from './smartCdn.ts' +import type { SmartCdnUrlOptions, SmartCdnUrlParams } from './smartCdn.ts' import type { SmartCdnImageCandidates, SmartCdnImagePolicyOptions } from './smartCdnImage.ts' import type { StorageGrantClaims, StorageGrantScope } from './storageGrant.ts' import { Buffer } from 'node:buffer' import { createHmac, timingSafeEqual } from 'node:crypto' -import { finishSmartCdnUrl, prepareSmartCdnUrl } from './smartCdn.ts' -import { createSmartCdnImageCandidates } from './smartCdnImage.ts' +import { finishSmartCdnUrl, getSmartCdnUrl, prepareSmartCdnUrl } from './smartCdn.ts' +import { createSmartCdnImageCandidates, smartCdnImageMaxDimension } from './smartCdnImage.ts' import { parseStorageGrantClaims } from './storageGrant.ts' export type { SignatureAlgorithm } from './index.ts' @@ -50,6 +50,46 @@ export interface SmartCdnImageCandidatesOptions extends SmartCdnImagePolicyOptio workspace: string } +/** Unsigned candidates for a server-declared public Template/input policy. */ +export interface SmartCdnUnsignedImageCandidatesOptions + extends Omit { + workspace: string + baseUrl?: string + urlParams?: SmartCdnUrlParams +} + +/** Uses the same format/width core without reading a clock or accepting signing credentials. */ +export function getSmartCdnImageCandidates( + opts: SmartCdnUnsignedImageCandidatesOptions, +): SmartCdnImageCandidates { + const { workspace, baseUrl } = opts + const urlParams = { ...opts.urlParams } + const sourceDimensions = + opts.sourceDimensions === undefined ? undefined : { ...opts.sourceDimensions } + return createSmartCdnImageCandidates( + { ...opts, sourceDimensions, expiresAt: undefined }, + (request) => { + const width = request.urlParams.w + if (typeof width !== 'number') throw new TypeError('Image candidates require a numeric width') + return getSmartCdnUrl({ + workspace, + baseUrl, + template: request.template, + input: request.input, + urlParams: { + ...urlParams, + ...request.urlParams, + // Preview Templates have a default height; width-only fit can silently cap the bitmap. + h: + sourceDimensions === undefined + ? smartCdnImageMaxDimension + : Math.max(1, Math.ceil((width * sourceDimensions.height) / sourceDimensions.width)), + }, + }) + }, + ) +} + export const signParamsSync = ( paramsString: string, authSecret: string, @@ -89,8 +129,11 @@ export function getSignedSmartCdnImageCandidates( throw new TypeError('authSecret is required') } - return createSmartCdnImageCandidates(opts, (request) => - getSignedSmartCdnUrl({ + return createSmartCdnImageCandidates(opts, (request) => { + // Unsigned candidates share this core; signed callers must choose their lifetime explicitly. + if (request.expiresAt === undefined) + throw new TypeError('expiresAt is required for signed image candidates') + return getSignedSmartCdnUrl({ authKey, authSecret, expiresAt: request.expiresAt, @@ -98,8 +141,8 @@ export function getSignedSmartCdnImageCandidates( template: request.template, urlParams: { ...request.urlParams }, workspace, - }), - ) + }) + }) } // ── storage grants ─────────────────────────────────────────────────────────── diff --git a/packages/utils/src/smartCdn.ts b/packages/utils/src/smartCdn.ts index 4aced615..84d2b1f8 100644 --- a/packages/utils/src/smartCdn.ts +++ b/packages/utils/src/smartCdn.ts @@ -176,11 +176,15 @@ export const prepareSmartCdnUrl = (opts: SmartCdnUrlOptions): PreparedSmartCdnUr } } -/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */ -export const finishSmartCdnUrl = ({ parts }: PreparedSmartCdnUrl, signatureHex: string): string => { +/** Finishes the common URL grammar, adding a signature only for signed delivery. */ +export const finishSmartCdnUrl = ( + { parts }: Pick, + signatureHex?: string, +): string => { const { baseUrl, templateSlug, inputField, queryParams } = parts - queryParams.set('sig', `sha256:${signatureHex}`) - return `${baseUrl}/${templateSlug}/${inputField}?${queryParams}` + if (signatureHex !== undefined) queryParams.set('sig', `sha256:${signatureHex}`) + const query = queryParams.toString() + return `${baseUrl}/${templateSlug}/${inputField}${query === '' ? '' : `?${query}`}` } /** @@ -196,10 +200,15 @@ export const getSmartCdnUrl = (opts: SmartCdnUnsignedUrlOptions): string => { // An unsigned builder must not emit fields that make the URL look partially or fully signed. for (const param of SIGNATURE_PARAMS) queryParams.delete(param) queryParams.sort() - const query = queryParams.toString() - return `${resolveBaseUrl(opts.baseUrl, workspaceSlug)}/${templateSlug}/${inputField}${ - query === '' ? '' : `?${query}` - }` + return finishSmartCdnUrl({ + parts: { + baseUrl: resolveBaseUrl(opts.baseUrl, workspaceSlug), + workspaceSlug, + templateSlug, + inputField, + queryParams, + }, + }) } const decodeOnce = (value: string, what: string): string => { diff --git a/packages/utils/src/smartCdnImage.ts b/packages/utils/src/smartCdnImage.ts index 1f925bd4..dd85c733 100644 --- a/packages/utils/src/smartCdnImage.ts +++ b/packages/utils/src/smartCdnImage.ts @@ -44,20 +44,22 @@ export interface SmartCdnImageSourceDimensions { } /** One rendition request passed to an injected Smart CDN signer. */ -export interface SmartCdnImageSignRequest { - expiresAt: number +export interface SmartCdnImageSignRequest { + expiresAt: Expiry input: string template: string urlParams: Readonly> } /** Injected signer that keeps responsive-image policy independent from credentials and runtimes. */ -export type SignSmartCdnImageRequest = (request: SmartCdnImageSignRequest) => string +export type SignSmartCdnImageRequest = ( + request: SmartCdnImageSignRequest, +) => string /** Framework-neutral options for deterministic Smart CDN image candidates. */ -export interface SmartCdnImagePolicyOptions { +export interface SmartCdnImagePolicyOptions { /** One absolute expiry in milliseconds since UNIX epoch, shared by every candidate. */ - expiresAt: number + expiresAt: Expiry /** Browser-safe fallback URL, kept separate from the Template-specific input value. */ fallbackUrl: string /** Formats and their quality values. Defaults to AVIF 45 and WebP 75. */ @@ -179,9 +181,9 @@ export function resolveSmartCdnImageWidths( * Creates signed responsive-image candidates while leaving credential storage and HMAC choice to * the injected signer. */ -export function createSmartCdnImageCandidates( - options: SmartCdnImagePolicyOptions, - sign: SignSmartCdnImageRequest, +export function createSmartCdnImageCandidates( + options: SmartCdnImagePolicyOptions, + sign: SignSmartCdnImageRequest, ): SmartCdnImageCandidates { const expiresAt = options.expiresAt const fallbackUrl = options.fallbackUrl @@ -197,9 +199,10 @@ export function createSmartCdnImageCandidates( const widthOptions = options.widths const widthsSnapshot = Array.isArray(widthOptions) ? [...widthOptions] : widthOptions - validatePositiveSafeInteger(expiresAt, 'expiresAt') - if (expiresAt < minimumMillisecondTimestamp) { - throw new RangeError('expiresAt must be a millisecond timestamp') + if (expiresAt !== undefined) { + validatePositiveSafeInteger(expiresAt, 'expiresAt') + if (expiresAt < minimumMillisecondTimestamp) + throw new RangeError('expiresAt must be a millisecond timestamp') } validateSmartCdnImageFallbackUrl(fallbackUrl) validateSmartCdnImageInput(input) diff --git a/packages/img/src/storagePath.ts b/packages/utils/src/storagePath.ts similarity index 91% rename from packages/img/src/storagePath.ts rename to packages/utils/src/storagePath.ts index dea89c5c..20e5d655 100644 --- a/packages/img/src/storagePath.ts +++ b/packages/utils/src/storagePath.ts @@ -53,7 +53,11 @@ export function validateStoragePath(path: string): void { } /** Validates one directory-boundary prefix; an empty prefix explicitly allows the workspace root. */ -export function validateStoragePathPrefix(prefix: string, index: number): void { +export function validateStoragePathPrefix( + prefix: string, + index: number, + name = 'storage.allowedPathPrefixes', +): void { if (prefix === '') return if ( typeof prefix !== 'string' || @@ -66,8 +70,6 @@ export function validateStoragePathPrefix(prefix: string, index: number): void { hasAmbiguousSegments(prefix) || hasInvalidSegments(prefix.slice(0, -1)) ) { - throw new TypeError( - `storage.allowedPathPrefixes[${index}] must be empty or one safe relative prefix ending in /`, - ) + throw new TypeError(`${name}[${index}] must be empty or one safe relative prefix ending in /`) } } diff --git a/packages/utils/test/node.test.ts b/packages/utils/test/node.test.ts index 4912f641..a1e8244d 100644 --- a/packages/utils/test/node.test.ts +++ b/packages/utils/test/node.test.ts @@ -136,6 +136,18 @@ describe('getSignedSmartCdnImageCandidates', () => { expect(workspaceReads).toBe(1) }) + it('requires an explicit expiry before signing image candidates', () => { + const { expiresAt: _expiresAt, ...withoutExpiry } = baseOptions + expect(() => + // @ts-expect-error JavaScript callers must not silently get a default signed lifetime. + getSignedSmartCdnImageCandidates(withoutExpiry), + ).toThrow('expiresAt is required for signed image candidates') + expect(() => + // @ts-expect-error Explicit undefined must fail just like an omitted expiry. + getSignedSmartCdnImageCandidates({ ...baseOptions, expiresAt: undefined }), + ).toThrow('expiresAt is required for signed image candidates') + }) + it('rejects values that the Built-in cannot execute safely', () => { expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, widths: [] })).toThrow( 'widths must contain at least one value', diff --git a/packages/utils/test/storagePath.test.ts b/packages/utils/test/storagePath.test.ts new file mode 100644 index 00000000..daa17507 --- /dev/null +++ b/packages/utils/test/storagePath.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest' + +import { validateStoragePath, validateStoragePathPrefix } from '../src/index.ts' + +test.each([ + '', + 'website/', + 'nested/Café/', + 'literal/%2e/', +])('accepts the explicit directory policy %j', (prefix) => { + expect(() => validateStoragePathPrefix(prefix, 0)).not.toThrow() +}) + +test.each([ + ' website/', + 'website/ ', + '/website/', + 'website', + 'a//', + 'a/../', + 'cafe\u0301/', +])('rejects ambiguous directory policy %j at every integration boundary', (prefix) => { + expect(() => validateStoragePathPrefix(prefix, 2, 'prefix')).toThrow('prefix[2]') +}) + +test('keeps an explicit root prefix distinct from an empty object path', () => { + expect(() => validateStoragePathPrefix('', 0)).not.toThrow() + expect(() => validateStoragePath('')).toThrow('non-empty relative strings') + expect(() => validateStoragePath('hero.jpg')).not.toThrow() +}) diff --git a/packages/utils/test/unsignedImage.test.ts b/packages/utils/test/unsignedImage.test.ts new file mode 100644 index 00000000..b3fd6dbd --- /dev/null +++ b/packages/utils/test/unsignedImage.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'vitest' + +import { getSmartCdnImageCandidates, parseSmartCdnUrl } from '../src/node.ts' + +test('unsigned candidates share width/format policy but need no credential, expiry or clock', () => { + const result = getSmartCdnImageCandidates({ + workspace: 'my-app', + template: 'builtin/public-preview@0.0.1', + input: 'website/hero.jpg', + widths: [800, 400, 800], + sourceDimensions: { width: 600, height: 400 }, + formats: { webp: 61 }, + fallbackUrl: '/fallback.jpg', + urlParams: { v: 'd41d8cd98f00b204', auth_key: 'ignored', sig: 'ignored', exp: 'ignored' }, + }) + expect(result.fallbackUrl).toBe('/fallback.jpg') + expect(result.sources[0]?.candidates.map(({ width }) => width)).toEqual([400, 600]) + for (const { url } of result.sources[0]?.candidates ?? []) { + const parsed = parseSmartCdnUrl(url) + expect(parsed.auth).toBeUndefined() + expect(parsed.urlParams).toMatchObject({ f: 'webp', q: '61', v: 'd41d8cd98f00b204' }) + expect(url).not.toMatch(/sig=|auth_key=|exp=/) + } +}) + +test('unsigned candidates preserve trusted baseUrl and cannot override validated transform dimensions', () => { + const result = getSmartCdnImageCandidates({ + workspace: 'my-app', + template: 'public-preview', + input: 'website/hero.jpg', + widths: [400], + fallbackUrl: '/fallback.jpg', + baseUrl: 'http://localhost:3020/file/{workspace}', + urlParams: { w: 9999 }, + }) + expect(new URL(result.sources[0]?.candidates[0]?.url ?? '').hostname).toBe('localhost') + expect(new URL(result.sources[0]?.candidates[0]?.url ?? '').searchParams.get('w')).toBe('400') +}) + +test('unsigned Storage previews do not inherit the Built-in 300px default height', () => { + const result = getSmartCdnImageCandidates({ + workspace: 'my-app', + template: 'builtin/public-preview@0.0.1', + input: 'website/hero.jpg', + sourceDimensions: { width: 2400, height: 1600 }, + widths: [960, 1920], + formats: { webp: 75 }, + fallbackUrl: '/fallback.jpg', + urlParams: { h: 300 }, + }) + const candidates = result.sources[0]?.candidates + expect(candidates).toHaveLength(2) + for (const candidate of candidates ?? []) { + const query = new URL(candidate.url).searchParams + const scale = Math.min(Number(query.get('w')) / 2400, Number(query.get('h') ?? 300) / 1600) + expect(Math.round(2400 * scale)).toBe(candidate.width) + } +}) + +test('fit rounds its height upward so a panorama is not narrower than its width descriptor', () => { + const result = getSmartCdnImageCandidates({ + workspace: 'my-app', + template: 'builtin/public-preview@0.0.1', + input: 'website/panorama.jpg', + sourceDimensions: { width: 2400, height: 10 }, + widths: [1000], + formats: { webp: 75 }, + fallbackUrl: '/fallback.jpg', + }) + const candidate = result.sources[0]?.candidates[0] + expect(candidate).toBeDefined() + if (candidate === undefined) throw new Error('Expected the panorama candidate') + const query = new URL(candidate.url).searchParams + const scale = Math.min(Number(query.get('w')) / 2400, Number(query.get('h')) / 10) + expect(Math.round(2400 * scale)).toBe(candidate.width) +}) diff --git a/scripts/fixtures/img-next/app/HydrationProbe.tsx b/scripts/fixtures/img-next/app/HydrationProbe.tsx new file mode 100644 index 00000000..fce98538 --- /dev/null +++ b/scripts/fixtures/img-next/app/HydrationProbe.tsx @@ -0,0 +1,22 @@ +'use client' + +import type { ReactNode } from 'react' + +import { useState, useSyncExternalStore } from 'react' + +const subscribe = (): (() => void) => () => undefined + +/** A user-visible interaction distinguishes parsed HTML from hydrated application JavaScript. */ +export function HydrationProbe(): ReactNode { + const [count, setCount] = useState(0) + const hydrated = useSyncExternalStore( + subscribe, + () => true, + () => false, + ) + return ( + + ) +} diff --git a/scripts/fixtures/img-next/app/TransloaditImage.tsx b/scripts/fixtures/img-next/app/TransloaditImage.tsx index 0f53a4f6..7d574cfe 100644 --- a/scripts/fixtures/img-next/app/TransloaditImage.tsx +++ b/scripts/fixtures/img-next/app/TransloaditImage.tsx @@ -1,10 +1,11 @@ -import { createTransloaditImage } from '@transloadit/img/next/server' +import { createStorageImages } from '@transloadit/img/next/server' -import { imageConfiguration } from './imageConfiguration.ts' +import { imageConfiguration } from './imageConfiguration' -const { Image } = createTransloaditImage({ - ...imageConfiguration, - storage: { allowedPathPrefixes: ['documents/'] }, +const { StorageImage } = createStorageImages({ + baseUrl: imageConfiguration.baseUrl, + allowedPathPrefixes: ['documents/'], + delivery: 'direct', }) -export { Image as TransloaditImage } +export { StorageImage as TransloaditImage } diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx index bfbd58e1..5f96dbfd 100644 --- a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx +++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx @@ -1,17 +1,16 @@ -import { createTransloaditImage } from '@transloadit/img/next/server' +import { createStorageImages } from '@transloadit/img/next/server' -import { imageConfiguration } from './imageConfiguration.ts' +import { authorizeFixtureImage } from '../browser-policy' +import { imageConfiguration } from './imageConfiguration' -const { Image, storageRoute } = createTransloaditImage({ +const { StorageImage, storageRoute } = createStorageImages({ ...imageConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { - authorize: ({ request }) => request.headers.get('authorization') === 'Bearer fixture', - basePath: '/fixture', - route: '/api/private-images', - }, - }, + allowedPathPrefixes: ['documents/'], + authorize: authorizeFixtureImage, + basePath: '/fixture', + route: '/api/private-images', + lifetime: 5 * 60 * 1000, + rotationIntervalMs: 30 * 1000, }) -export { Image as TransloaditRedirectImage, storageRoute } +export { StorageImage as TransloaditRedirectImage, storageRoute } diff --git a/scripts/fixtures/img-next/app/api/browser-images/route.ts b/scripts/fixtures/img-next/app/api/browser-images/route.ts new file mode 100644 index 00000000..bc213c8f --- /dev/null +++ b/scripts/fixtures/img-next/app/api/browser-images/route.ts @@ -0,0 +1 @@ +export { browserStorageRoute as GET, browserStorageRoute as HEAD } from '../../browser/BrowserImage' diff --git a/scripts/fixtures/img-next/app/api/fixture-session/route.ts b/scripts/fixtures/img-next/app/api/fixture-session/route.ts new file mode 100644 index 00000000..92a0a883 --- /dev/null +++ b/scripts/fixtures/img-next/app/api/fixture-session/route.ts @@ -0,0 +1,12 @@ +import { cookies } from 'next/headers' + +/** Synthetic local-only login; unlike a Server Action, this requires router.refresh(). */ +export async function POST(): Promise { + const cookieStore = await cookies() + cookieStore.set('fixture-session', 'fixture', { + httpOnly: true, + sameSite: 'strict', + path: '/', + }) + return new Response(null, { status: 204 }) +} diff --git a/scripts/fixtures/img-next/app/api/private-images/route.ts b/scripts/fixtures/img-next/app/api/private-images/route.ts index 0b4e245a..e2117bac 100644 --- a/scripts/fixtures/img-next/app/api/private-images/route.ts +++ b/scripts/fixtures/img-next/app/api/private-images/route.ts @@ -1 +1 @@ -export { storageRoute as GET } from '../../TransloaditRedirectImage.tsx' +export { storageRoute as GET, storageRoute as HEAD } from '../../TransloaditRedirectImage' diff --git a/scripts/fixtures/img-next/app/api/storage-images/route.ts b/scripts/fixtures/img-next/app/api/storage-images/route.ts new file mode 100644 index 00000000..c8874c1d --- /dev/null +++ b/scripts/fixtures/img-next/app/api/storage-images/route.ts @@ -0,0 +1 @@ +export { GET, HEAD } from '@transloadit/img/next/route' diff --git a/scripts/fixtures/img-next/app/art-direction/page.tsx b/scripts/fixtures/img-next/app/art-direction/page.tsx new file mode 100644 index 00000000..20440e0b --- /dev/null +++ b/scripts/fixtures/img-next/app/art-direction/page.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +import { BrowserImage } from '../browser/BrowserImage' + +export default function Page(): ReactNode { + return ( +
+

Art-directed hero

+
+ +
+
+ ) +} diff --git a/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx index be01d040..ce813a81 100644 --- a/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx +++ b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { notFound } from 'next/navigation.js' +import { Suspense } from 'react' -import { TransloaditImage } from '../../../TransloaditImage.tsx' -import { TransloaditRedirectImage } from '../../../TransloaditRedirectImage.tsx' +import { TransloaditImage } from '../../../TransloaditImage' +import { TransloaditRedirectImage } from '../../../TransloaditRedirectImage' interface PageProps { params: Promise<{ count: string; delivery: string }> @@ -11,9 +12,7 @@ interface PageProps { const benchmarkCounts = new Set([1, 20, 100]) -export const instant = false - -export default async function Page({ params }: PageProps): Promise { +async function Benchmark({ params }: PageProps): Promise { const { count: countValue, delivery } = await params const count = Number(countValue) if (!benchmarkCounts.has(count) || (delivery !== 'direct' && delivery !== 'redirect')) { @@ -29,9 +28,18 @@ export default async function Page({ params }: PageProps): Promise { key={index} sizes="200px" src={`documents/benchmark-${index + 1}.jpg`} + style={{ display: 'block', height: 'auto', width: 200 }} width={400} />, ) } return
{images}
} + +export default function Page({ params }: PageProps): ReactNode { + return ( + + + + ) +} diff --git a/scripts/fixtures/img-next/app/browser/BrowserImage.tsx b/scripts/fixtures/img-next/app/browser/BrowserImage.tsx new file mode 100644 index 00000000..6f4c1faf --- /dev/null +++ b/scripts/fixtures/img-next/app/browser/BrowserImage.tsx @@ -0,0 +1,16 @@ +import { createStorageImages } from '@transloadit/img/next/server' + +import { authorizeFixtureImage } from '../../browser-policy' +import { imageConfiguration } from '../imageConfiguration' + +/** Short grants make actual expiration testable without changing the package's clock/defaults. */ +export const { StorageImage: BrowserImage, storageRoute: browserStorageRoute } = + createStorageImages({ + ...imageConfiguration, + allowedPathPrefixes: ['documents/'], + authorize: authorizeFixtureImage, + basePath: '/fixture', + route: '/api/browser-images', + lifetime: 10_000, + rotationInterval: '1s', + }) diff --git a/scripts/fixtures/img-next/app/browser/page.tsx b/scripts/fixtures/img-next/app/browser/page.tsx new file mode 100644 index 00000000..8098bdfe --- /dev/null +++ b/scripts/fixtures/img-next/app/browser/page.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' + +import { BrowserImage } from './BrowserImage' + +export default function Page(): ReactNode { + return ( +
+

Private image lifecycle

+ +

After the hero

+ +

After the avatar

+
+ +
+
+ ) +} diff --git a/scripts/fixtures/img-next/app/delivery/StorageImages.tsx b/scripts/fixtures/img-next/app/delivery/StorageImages.tsx new file mode 100644 index 00000000..2d765061 --- /dev/null +++ b/scripts/fixtures/img-next/app/delivery/StorageImages.tsx @@ -0,0 +1,12 @@ +import { createStorageImages } from '@transloadit/img/next/server' + +import { authorizeFixtureImage } from '../../browser-policy' +import { imageConfiguration } from '../imageConfiguration' + +export const { StorageImage: DeliveryImage, storageRoute: deliveryRoute } = createStorageImages({ + allowedPathPrefixes: ['documents/'], + authorize: authorizeFixtureImage, + basePath: '/fixture', + baseUrl: imageConfiguration.baseUrl, + public: ['documents/public/'], +}) diff --git a/scripts/fixtures/img-next/app/delivery/page.tsx b/scripts/fixtures/img-next/app/delivery/page.tsx new file mode 100644 index 00000000..eb1fd467 --- /dev/null +++ b/scripts/fixtures/img-next/app/delivery/page.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' + +import { DeliveryImage } from './StorageImages' + +export default function Page(): ReactNode { + return ( +
+

Static and private delivery

+ + +
+ ) +} diff --git a/scripts/fixtures/img-next/app/image-error/SignIn.tsx b/scripts/fixtures/img-next/app/image-error/SignIn.tsx new file mode 100644 index 00000000..ae5bd552 --- /dev/null +++ b/scripts/fixtures/img-next/app/image-error/SignIn.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { ReactNode } from 'react' + +import { useRouter } from 'next/navigation' +import { useTransition } from 'react' + +/** Local fixture sign-in followed by an ordinary same-page RSC refresh. */ +export function SignIn(): ReactNode { + const router = useRouter() + const [pending, startTransition] = useTransition() + return ( + + ) +} diff --git a/scripts/fixtures/img-next/app/image-error/page.tsx b/scripts/fixtures/img-next/app/image-error/page.tsx new file mode 100644 index 00000000..b4b1e993 --- /dev/null +++ b/scripts/fixtures/img-next/app/image-error/page.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' + +import { cookies } from 'next/headers' +import { Suspense } from 'react' + +import { BrowserImage } from '../browser/BrowserImage' +import { SignIn } from './SignIn' + +async function PrivatePreview(): Promise { + const signedIn = (await cookies()).get('fixture-session')?.value === 'fixture' + return ( + Sign in to see this image

} + /> + ) +} + +export default function Page(): ReactNode { + return ( +
+

Private image failure

+ Reading session…

}> + +
+ +
+ ) +} diff --git a/scripts/fixtures/img-next/app/imageConfiguration.ts b/scripts/fixtures/img-next/app/imageConfiguration.ts index 64aa4014..4dc0f263 100644 --- a/scripts/fixtures/img-next/app/imageConfiguration.ts +++ b/scripts/fixtures/img-next/app/imageConfiguration.ts @@ -1,6 +1,6 @@ export const imageConfiguration = { authKey: 'fixture-auth-key', authSecret: 'fixture-secret-must-never-reach-the-browser', - baseUrl: 'https://cdn.example/file/{workspace}', + baseUrl: `${process.env.IMG_FIXTURE_CDN_ORIGIN ?? 'https://cdn.example'}/file/{workspace}`, workspace: 'fixture', } diff --git a/scripts/fixtures/img-next/app/layout.tsx b/scripts/fixtures/img-next/app/layout.tsx index d50e935a..8bcae18d 100644 --- a/scripts/fixtures/img-next/app/layout.tsx +++ b/scripts/fixtures/img-next/app/layout.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' +import { HydrationProbe } from './HydrationProbe' + interface LayoutProps { children: ReactNode } @@ -7,7 +9,13 @@ interface LayoutProps { export default function Layout({ children }: LayoutProps): ReactNode { return ( - {children} + + + + + {children} + + ) } diff --git a/scripts/fixtures/img-next/app/layouts/page.tsx b/scripts/fixtures/img-next/app/layouts/page.tsx new file mode 100644 index 00000000..ac18f93f --- /dev/null +++ b/scripts/fixtures/img-next/app/layouts/page.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +import { BrowserImage } from '../browser/BrowserImage' + +export default function Page(): ReactNode { + return ( +
+

Layout modes

+
+ +
+
+ ) +} diff --git a/scripts/fixtures/img-next/app/package-alpha/page.tsx b/scripts/fixtures/img-next/app/package-alpha/page.tsx new file mode 100644 index 00000000..a78beec4 --- /dev/null +++ b/scripts/fixtures/img-next/app/package-alpha/page.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' + +import { StorageImage } from '@transloadit/img/next' + +export default function Page(): ReactNode { + return ( + <> + + + + ) +} diff --git a/scripts/fixtures/img-next/app/package-images/page.tsx b/scripts/fixtures/img-next/app/package-images/page.tsx new file mode 100644 index 00000000..4b738b0e --- /dev/null +++ b/scripts/fixtures/img-next/app/package-images/page.tsx @@ -0,0 +1,16 @@ +import { StorageImage } from '@transloadit/img/next' + +export default function Page() { + return ( +
+

Images from the package

+ + Sign in to see this image

} + /> +
+ ) +} diff --git a/scripts/fixtures/img-next/app/package-public/page.tsx b/scripts/fixtures/img-next/app/package-public/page.tsx new file mode 100644 index 00000000..20af677e --- /dev/null +++ b/scripts/fixtures/img-next/app/package-public/page.tsx @@ -0,0 +1,13 @@ +import { StorageImage } from '@transloadit/img/next' + +export default function Page() { + return ( + + ) +} diff --git a/scripts/fixtures/img-next/app/public-image/page.tsx b/scripts/fixtures/img-next/app/public-image/page.tsx new file mode 100644 index 00000000..3505235c --- /dev/null +++ b/scripts/fixtures/img-next/app/public-image/page.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' + +import { createStorageImages } from '@transloadit/img/next/server' + +import { imageConfiguration } from '../imageConfiguration' + +const images = { + 'website/hero.jpg': { + path: 'website/hero.jpg', + width: 2400, + height: 1600, + md5hash: 'd41d8cd98f00b204e9800998ecf8427e', + }, + 'website/small.jpg': { path: 'website/small.jpg', width: 320, height: 240 }, +} + +const { StorageImage } = createStorageImages({ + workspace: 'fixture', + images, + public: ['website/'], + baseUrl: imageConfiguration.baseUrl, +}) + +export default function Page(): ReactNode { + return ( +
+

Static public image

+ + +
+ ) +} diff --git a/scripts/fixtures/img-next/app/storage-image/page.tsx b/scripts/fixtures/img-next/app/storage-image/page.tsx index 992e2b5b..2c5b3d36 100644 --- a/scripts/fixtures/img-next/app/storage-image/page.tsx +++ b/scripts/fixtures/img-next/app/storage-image/page.tsx @@ -1,16 +1,28 @@ import type { ReactNode } from 'react' -import { TransloaditImage } from '../TransloaditImage.tsx' +import { TransloaditImage } from '../TransloaditImage' export default function Page(): ReactNode { return ( - } - width={400} - /> +
+ +

After the hero

+ +

After the avatar

+
) } diff --git a/scripts/fixtures/img-next/app/storage-redirect/page.tsx b/scripts/fixtures/img-next/app/storage-redirect/page.tsx index 4e418674..aa8612a5 100644 --- a/scripts/fixtures/img-next/app/storage-redirect/page.tsx +++ b/scripts/fixtures/img-next/app/storage-redirect/page.tsx @@ -1,17 +1,18 @@ import type { ReactNode } from 'react' -import { TransloaditRedirectImage } from '../TransloaditRedirectImage.tsx' +import { TransloaditRedirectImage } from '../TransloaditRedirectImage' export default function Page(): ReactNode { return ( ) } diff --git a/scripts/fixtures/img-next/app/transparency/page.tsx b/scripts/fixtures/img-next/app/transparency/page.tsx new file mode 100644 index 00000000..638d7c00 --- /dev/null +++ b/scripts/fixtures/img-next/app/transparency/page.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +import { BrowserImage } from '../browser/BrowserImage' + +const source = { path: 'documents/alpha.png', width: 64, height: 64 } + +export default function Page(): ReactNode { + return ( +
+

Transparent previews

+ + + +
+ ) +} diff --git a/scripts/fixtures/img-next/browser-cdn.ts b/scripts/fixtures/img-next/browser-cdn.ts new file mode 100644 index 00000000..85c66611 --- /dev/null +++ b/scripts/fixtures/img-next/browser-cdn.ts @@ -0,0 +1,166 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +import assert from 'node:assert/strict' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { once } from 'node:events' +import { createServer } from 'node:http' + +import sharp from 'sharp' + +import { imageConfiguration } from './app/imageConfiguration.ts' + +interface FixtureCdn { + requests: { url: string; status: number; cookie: string | undefined }[] + errors: unknown[] + close(): Promise +} + +/** An owned origin with independent signature/public-prefix enforcement and real image bytes. */ +export async function startFixtureCdn(origin: string): Promise { + const endpoint = new URL(origin) + assert.equal(endpoint.hostname, 'localhost') + assert.equal(endpoint.protocol, 'http:') + const requests: { url: string; status: number; cookie: string | undefined }[] = [] + const errors: unknown[] = [] + const images = new Map>() + const transparentSource = await sharp({ + create: { width: 64, height: 64, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .composite([ + { + input: { + create: { + width: 32, + height: 32, + channels: 4, + background: { r: 45, g: 110, b: 160, alpha: 1 }, + }, + }, + left: 16, + top: 16, + }, + ]) + .png() + .toBuffer() + async function respond(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? '/', origin) + const signature = url.searchParams.get('sig') + url.searchParams.delete('sig') + url.searchParams.sort() + const expected = `sha256:${createHmac('sha256', imageConfiguration.authSecret) + .update(`${url.pathname.slice('/file/'.length)}?${url.searchParams}`) + .digest('hex')}` + const authenticated = + signature !== null && + /^sha256:[a-f0-9]{64}$/.test(signature) && + timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + const width = Number(url.searchParams.get('w')) + const height = Number(url.searchParams.get('h')) + // Mirror the verified defaults of the two pinned API2 preview Built-ins. + const format = url.searchParams.get('f') ?? 'jpg' + const strategy = url.searchParams.get('r') ?? 'pad' + const background = url.searchParams.get('bg') ?? '#ffffff' + const mime = format === 'jpg' ? 'image/jpeg' : `image/${format}` + const path = decodeURIComponent(url.pathname) + const publicTemplate = '/file/fixture/builtin/public-preview@0.0.1/' + const privateTemplate = '/file/fixture/builtin/storage-preview@0.0.2/' + const isPublicTemplate = path.startsWith(publicTemplate) + const published = + isPublicTemplate && + ['website/', 'documents/public/'].some((prefix) => + path.slice(publicTemplate.length).startsWith(prefix), + ) + const validSignature = + authenticated && + url.searchParams.get('auth_key') === imageConfiguration.authKey && + Number(url.searchParams.get('exp')) > Date.now() + // A supplied bad signature must never fall through to anonymous public delivery. + const authorized = signature !== null ? validSignature : published + const version = url.searchParams.get('v') + const accepted = + (isPublicTemplate || path.startsWith(privateTemplate)) && + authorized && + (version === null || (isPublicTemplate && /^[A-Za-z0-9_-]{1,64}$/.test(version))) && + Number.isSafeInteger(width) && + width > 0 && + width <= 2400 && + Number.isSafeInteger(height) && + height > 0 && + height <= 2400 && + (strategy === 'pad' || strategy === 'fillcrop') && + (format === 'avif' || format === 'webp' || format === 'png' || format === 'jpg') && + (format === 'jpg' ? /^#[0-9a-f]{6}(?:ff)?$/i.test(background) : background === '#00000000') + requests.push({ + url: new URL(request.url ?? '/', origin).href, + status: accepted ? 200 : 403, + cookie: request.headers.cookie, + }) + if (!accepted) { + response.writeHead(403, { 'Cache-Control': 'no-store' }).end() + return + } + const avatar = decodeURIComponent(url.pathname).endsWith('/documents/avatar.jpg') + const small = decodeURIComponent(url.pathname).endsWith('/website/small.jpg') + const transparent = ['/documents/alpha.png', '/website/alpha.png'].some((suffix) => + path.endsWith(suffix), + ) + const key = `${avatar}/${small}/${transparent}/${width}/${height}/${format}/${strategy}/${background}` + let bytes = images.get(key) + if (bytes === undefined) { + const source = transparent + ? sharp(transparentSource) + : sharp({ + create: { + width: small ? 320 : avatar ? 400 : 2400, + height: small ? 240 : avatar ? 300 : 1600, + channels: 3, + background: { r: 45, g: 110, b: 160 }, + }, + }) + const image = source.resize(width, height, { + fit: strategy === 'fillcrop' ? 'cover' : 'contain', + background, + }) + bytes = ( + format === 'avif' + ? image.avif() + : format === 'webp' + ? image.webp() + : format === 'png' + ? image.png() + : image.flatten({ background }).jpeg() + ).toBuffer() + images.set(key, bytes) + } + const body = await bytes + response + .writeHead(200, { + 'Cache-Control': isPublicTemplate + ? version === null + ? 'public, max-age=259200, s-maxage=86400' + : 'public, max-age=31536000, s-maxage=31536000, immutable' + : 'no-store', + 'Content-Type': mime, + 'Content-Length': body.length, + }) + .end(body) + } + const server = createServer((request, response) => { + respond(request, response).catch((error: unknown) => { + errors.push(error) + response.writeHead(500).end() + }) + }) + server.listen(Number(endpoint.port), '127.0.0.1') + await once(server, 'listening') + return { + requests, + errors, + async close(): Promise { + server.closeAllConnections() + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + }, + } +} diff --git a/scripts/fixtures/img-next/browser-policy.ts b/scripts/fixtures/img-next/browser-policy.ts new file mode 100644 index 00000000..109f1a0e --- /dev/null +++ b/scripts/fixtures/img-next/browser-policy.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +/** Local test control, shared by the browser runner and its isolated Next process. */ +export const revokedAccessFile = resolve(process.cwd(), '.browser-access-revoked') + +/** A fixture session represents application access, not a Smart CDN credential. */ +export function authorizeFixtureImage({ request }: { request: Request }): boolean { + const authenticated = request.headers + .get('cookie') + ?.split(';') + .some((cookie) => cookie.trim() === 'fixture-session=fixture') + return authenticated === true && !existsSync(revokedAccessFile) +} diff --git a/scripts/fixtures/img-next/browser.spec.ts b/scripts/fixtures/img-next/browser.spec.ts new file mode 100644 index 00000000..706a80c8 --- /dev/null +++ b/scripts/fixtures/img-next/browser.spec.ts @@ -0,0 +1,1174 @@ +import type { Locator, Page, Request, Response, Route } from '@playwright/test' + +import assert from 'node:assert/strict' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { setImmediate } from 'node:timers/promises' + +import { test as base, expect } from '@playwright/test' +import sharp from 'sharp' + +import { imageConfiguration } from './app/imageConfiguration.ts' +import { startFixtureCdn } from './browser-cdn.ts' +import { revokedAccessFile } from './browser-policy.ts' + +declare global { + interface Window { + fixtureLcpMs: number | null + } +} + +interface ImageEvidence { + bytes: number + corner?: number[] + contentType: string | undefined + height: number | undefined + url: string + width: number | undefined +} + +interface BrowserAudit { + committedRefreshes: Set + expectedFailures: Map + images: ImageEvidence[] + loadNativeImage(url: string): Promise<{ + loaded: boolean + status: number + headers: Record + bodyLength: number | undefined + }> +} + +const cdnOrigin = process.env.IMG_FIXTURE_CDN_ORIGIN +assert(cdnOrigin, 'The packed fixture must provide its own CDN origin') +let cdn: Awaited> + +const test = base.extend<{ audit: BrowserAudit }>({ + audit: [ + async ({ page, context, browserName, javaScriptEnabled }, use, info) => { + const expectedFailures = new Map() + const images: ImageEvidence[] = [] + const errors: string[] = [] + const failedRequests: Request[] = [] + const committedRefreshes = new Set() + const cancelledRefreshes: string[] = [] + const disabledScriptPreloads: string[] = [] + // The empty scaffold intentionally has no favicon; it is not an image delivery failure. + expectedFailures.set(new URL('/favicon.ico', info.project.use.baseURL).href, 404) + await rm(revokedAccessFile, { force: true }) + await context.addCookies([ + { + name: 'fixture-session', + value: 'fixture', + domain: '127.0.0.1', + path: '/', + httpOnly: true, + sameSite: 'Strict', + }, + ]) + if (browserName === 'chromium') { + // Fulfilled HTML has no network address-space classification. Explicitly grant access + // to our loopback fixture even in the no-JavaScript format-fallback scenario. + await context.grantPermissions(['local-network-access'], { + origin: info.project.use.baseURL, + }) + } + async function observe(page: Page): Promise { + await page.addInitScript(() => { + window.fixtureLcpMs = null + if (PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')) { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) window.fixtureLcpMs = entry.startTime + }).observe({ buffered: true, type: 'largest-contentful-paint' }) + } + }) + await page.route('**/*', (route) => { + const url = new URL(route.request().url()) + if (url.origin === cdnOrigin || url.origin === info.project.use.baseURL) + return route.continue() + errors.push(`Unexpected external request: ${url.origin}`) + return route.abort('blockedbyclient') + }) + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() !== 'error') return + const status = expectedFailures.get(message.location().url) + if (status !== undefined && message.text().includes(String(status))) return + errors.push(message.text()) + }) + page.on('requestfailed', (request) => { + failedRequests.push(request) + }) + page.on('response', async (response) => { + if ( + response.status() >= 400 && + expectedFailures.get(response.url()) !== response.status() + ) { + errors.push(`HTTP ${response.status()}: ${response.url()}`) + } + if (!response.ok() || response.request().resourceType() !== 'image') return + const bytes = await response.body() + const metadata = await sharp(bytes).metadata() + const corner = ['/documents/alpha.png', '/website/alpha.png'].some((suffix) => + decodeURIComponent(new URL(response.url()).pathname).endsWith(suffix), + ) + ? [ + ...(await sharp(bytes) + .extract({ left: 0, top: 0, width: 1, height: 1 }) + .ensureAlpha() + .raw() + .toBuffer()), + ] + : undefined + images.push({ + bytes: bytes.length, + corner, + contentType: response.headers()['content-type'], + height: metadata.height, + width: metadata.width, + url: response.url(), + }) + }) + } + await observe(page) + await use({ + committedRefreshes, + expectedFailures, + images, + async loadNativeImage(url) { + const browser = context.browser() + assert(browser) + // An existing WebKit document may reuse its already-decoded image without HTTP. + // A separate browsing session proves a new grant, without changing the original URL. + const probeContext = await browser.newContext({ + storageState: { cookies: await context.cookies(), origins: [] }, + }) + try { + if (browserName === 'chromium') { + await probeContext.grantPermissions(['local-network-access'], { + origin: info.project.use.baseURL, + }) + } + const probe = await probeContext.newPage() + await observe(probe) + await probe.route('**/fixture/native-probe', (route) => + route.fulfill({ + contentType: 'text/html', + body: 'Native image probe', + }), + ) + await probe.goto(new URL('/fixture/native-probe', page.url()).href) + const sourceResponses: Response[] = [] + probe.on('response', (response) => { + if (response.url() === url) sourceResponses.push(response) + }) + const loaded = await probe.evaluate(async (src) => { + const image = new Image() + image.src = src + try { + await image.decode() + return true + } catch { + return false + } + }, url) + const sourceResponse = sourceResponses[0] + assert(sourceResponse, 'The native probe must make an actual HTTP request') + return { + loaded, + status: sourceResponse.status(), + headers: sourceResponse.headers(), + bodyLength: + sourceResponse.status() >= 400 ? (await sourceResponse.body()).length : undefined, + } + } finally { + try { + for (const probe of probeContext.pages()) { + await probe.removeAllListeners('response', { behavior: 'wait' }) + } + } finally { + await probeContext.close() + } + } + }, + }) + // Stop accepting reads before draining: Promise.all on a growing array misses late responses. + await page.removeAllListeners('response', { behavior: 'wait' }) + for (const request of failedRequests) { + if (expectedFailures.has(request.url())) continue + const url = new URL(request.url()) + // Chromium reports disabled script preloads as CSP failures. Only the deliberate + // no-JS scenario's same-origin Next chunks are exempt, never image or network errors. + if ( + javaScriptEnabled === false && + browserName === 'chromium' && + request.resourceType() === 'script' && + request.failure()?.errorText === 'csp' && + url.origin === new URL(info.project.use.baseURL ?? '').origin && + url.pathname.startsWith('/fixture/_next/static/chunks/') && + url.pathname.endsWith('.js') + ) { + disabledScriptPreloads.push(request.url()) + continue + } + // Chromium may cancel Flight after React commits. Only the exact successful refresh + // whose UI, decoded image and preserved client state the test verified is exempt. + if ( + committedRefreshes.has(request) && + request.failure()?.errorText === 'net::ERR_ABORTED' + ) { + cancelledRefreshes.push(request.url()) + continue + } + errors.push(`Failed request: ${request.url()}`) + } + await info.attach('native-image-responses', { + body: JSON.stringify( + { + images, + errors, + cancelledRefreshes, + disabledScriptPreloads, + expectedFailures: [...expectedFailures], + }, + null, + 2, + ), + contentType: 'application/json', + }) + expect(errors).toEqual([]) + expect(images.every((image) => new URL(image.url).origin === cdnOrigin)).toBe(true) + expect(images.every((image) => !image.url.includes(imageConfiguration.authSecret))).toBe(true) + for (const image of images) { + const url = new URL(image.url) + expect(image.width).toBe(Number(url.searchParams.get('w'))) + expect(image.height).toBe(Number(url.searchParams.get('h'))) + const format = url.searchParams.get('f') ?? 'jpg' + expect(image.contentType).toBe(`image/${format === 'jpg' ? 'jpeg' : format}`) + } + await rm(revokedAccessFile, { force: true }) + }, + { auto: true }, + ], +}) + +test.beforeAll(async () => { + cdn = await startFixtureCdn(cdnOrigin) +}) + +test('response auditing waits for unfinished native body reads during listener cleanup', async ({ + page, + audit, +}) => { + const started = Promise.withResolvers() + const release = Promise.withResolvers() + page.prependListener('response', (response: Response) => { + if (!response.ok() || response.request().resourceType() !== 'image') return + const body = response.body.bind(response) + // The browser decodes normally; only the audit's own body read is held at the teardown edge. + response.body = async () => { + started.resolve() + await release.promise + return body() + } + }) + try { + await page.goto('/fixture/cli-image/app/storage-image-example') + await decode(page.getByRole('img', { name: 'hero', exact: true })) + await started.promise + let drained = false + const drain = page.removeAllListeners('response', { behavior: 'wait' }).then(() => { + drained = true + }) + try { + await setImmediate() + expect(drained).toBe(false) + } finally { + release.resolve() + await drain + } + expect(audit.images).toEqual( + expect.arrayContaining([expect.objectContaining({ width: 960, height: 640 })]), + ) + } finally { + release.resolve() + } +}) + +test('keeps transparent corners in native AVIF/WebP/PNG and composites JPEG onto its signed color', async ({ + page, + audit, +}) => { + await page.goto('/fixture/transparency') + await expect(page.getByRole('heading', { name: 'Transparent previews' })).toBeVisible() + await decode(page.getByRole('img', { name: 'AVIF logo', exact: true })) + await decode(page.getByRole('img', { name: 'WebP logo', exact: true })) + const png = page.getByRole('img', { name: 'PNG logo', exact: true }) + await decode(png) + await expect.poll(() => audit.images.filter((image) => image.corner?.[3] === 0).length).toBe(3) + expect(audit.images.map((image) => image.contentType)).toEqual( + expect.arrayContaining(['image/avif', 'image/webp', 'image/png']), + ) + const fallback = await png.getAttribute('src') + assert(fallback) + expect((await audit.loadNativeImage(new URL(fallback, page.url()).href)).loaded).toBe(true) + const jpeg = audit.images.find((image) => image.contentType === 'image/jpeg') + expect(jpeg?.corner?.[3]).toBe(255) + expect(jpeg?.corner?.[0]).toBeCloseTo(34, -1) + expect(jpeg?.corner?.[1]).toBeCloseTo(68, -1) + expect(jpeg?.corner?.[2]).toBeCloseTo(102, -1) +}) +test.afterAll(async () => { + if (cdn === undefined) return + await cdn.close() + expect(cdn.errors).toEqual([]) + expect(cdn.requests.every((request) => request.cookie === undefined)).toBe(true) +}) + +async function decode(image: Locator): Promise { + // Scrolling schedules lazy selection asynchronously; decode() before that can reject an empty + // request even though the real image loads successfully on the next intersection-observer tick. + await expect + .poll(() => + image.evaluate((element) => (element instanceof HTMLImageElement ? element.naturalWidth : 0)), + ) + .toBeGreaterThan(0) + await image.evaluate(async (element) => { + if (!(element instanceof HTMLImageElement)) throw new Error('Expected native image') + await element.decode() + }) +} + +async function captureBeforeJavaScript(page: Page): Promise { + // Deliberately held scripts keep document.fonts.ready pending even with system fonts. Capture + // Chromium's compositor directly without releasing those scripts just to take a screenshot. + const client = await page.context().newCDPSession(page) + try { + const screenshot = await client.send('Page.captureScreenshot') + return Buffer.from(screenshot.data, 'base64') + } finally { + await client.detach() + } +} + +function expectSameBox( + actual: Awaited>, + expected: Awaited>, +): void { + assert(actual && expected) + expect(actual.x).toBe(expected.x) + expect(actual.width).toBe(expected.width) + expect(actual.height).toBe(expected.height) + // Encoders round candidate heights to integer pixels (640×427 vs 2400×1600), so native + // height:auto can move following content by a fraction of one CSS pixel after decoding. + expect(actual.y).toBeCloseTo(expected.y, 0) +} + +function redirectResponse(page: Page, capability: string): Promise { + return page.waitForResponse( + (response) => response.url() === capability && response.request().resourceType() === 'image', + ) +} + +async function waitForExpiry(url: string): Promise { + const expiry = Number(new URL(url).searchParams.get('exp')) + expect(expiry).toBeGreaterThan(0) + expect(expiry - Date.now()).toBeLessThanOrEqual(11_000) + await expect.poll(() => Date.now(), { timeout: 12_000, intervals: [100] }).toBeGreaterThan(expiry) +} + +test('native image requests authorize with an HttpOnly session cookie, without Bearer headers', async ({ + baseURL, + browserName, + context, + page, +}) => { + const imageResponse = page.waitForResponse( + (response) => + response.request().resourceType() === 'image' && + response.url().includes('/api/private-images'), + ) + await page.goto('/fixture/storage-redirect') + const response = await imageResponse + expect(response.request().headers().authorization).toBeUndefined() + // WebKit's protocol omits the Cookie header. The cookie-only authorizer and logged-out + // rejection below verify its server-side effect in both engines. + if (browserName === 'chromium') + expect(await response.request().headerValue('cookie')).toBe('fixture-session=fixture') + expect(response.status()).toBe(307) + expect(response.headers()['cache-control']).toBe('private, no-store') + await decode(page.getByRole('img', { name: 'Authorized Storage fixture' })) + expect( + (await context.cookies()).find((cookie) => cookie.name === 'fixture-session')?.httpOnly, + ).toBe(true) + expect(await page.evaluate(() => document.cookie)).toBe('') + expect(await page.content()).not.toContain(imageConfiguration.authSecret) + assert(baseURL) + expect(new URL(cdnOrigin).hostname).not.toBe(new URL(baseURL).hostname) +}) + +test('GET and HEAD share private authorization while explicit public prefixes are cacheable', async ({ + context, + page, +}) => { + await page.goto('/fixture/delivery') + await expect(page.getByRole('heading', { name: 'Static and private delivery' })).toBeVisible() + const publicImage = page.getByRole('img', { name: 'Public website image' }) + const privateImage = page.getByRole('img', { name: 'Private account image' }) + await decode(publicImage) + await decode(privateImage) + const publicUrl = await publicImage.getAttribute('src') + const privateUrl = await privateImage.getAttribute('src') + assert(publicUrl) + assert(privateUrl) + const get = await context.request.get(privateUrl, { maxRedirects: 0 }) + const head = await context.request.head(privateUrl, { maxRedirects: 0 }) + expect(get.status()).toBe(307) + expect(new URL(get.headers().location).searchParams.has('sig')).toBe(true) + expect(head.status()).toBe(307) + expect(head.headers().location).toBe(get.headers().location) + expect(head.headers()['cache-control']).toBe('private, no-store') + expect(await head.body()).toHaveLength(0) + await context.clearCookies() + const deniedGet = await context.request.get(privateUrl, { maxRedirects: 0 }) + const deniedHead = await context.request.head(privateUrl, { maxRedirects: 0 }) + expect(deniedGet.status()).toBe(404) + expect(deniedHead.status()).toBe(404) + expect(deniedHead.headers()['cache-control']).toBe('private, no-store') + const publicGet = await context.request.get(publicUrl, { maxRedirects: 0 }) + const publicHead = await context.request.head(publicUrl, { maxRedirects: 0 }) + expect(publicGet.status()).toBe(200) + expect(publicHead.status()).toBe(200) + expect(new URL(publicUrl).searchParams.has('sig')).toBe(false) + expect(new URL(publicUrl).searchParams.has('exp')).toBe(false) + expect(new URL(publicUrl).searchParams.get('v')).toBe('d41d8cd98f00b204') + expect(publicGet.headers()['cache-control']).toMatch( + /^public, max-age=31536000, s-maxage=31536000, immutable$/, + ) + expect(publicHead.headers()['cache-control']).toBe(publicGet.headers()['cache-control']) + expect(await publicHead.body()).toHaveLength(0) +}) + +for (const width of [390, 1200]) { + test(`art direction downloads only the matching real crop at ${width}px`, async ({ + page, + }, info) => { + await page.setViewportSize({ width, height: 1000 }) + const offset = cdn.requests.length + await page.goto('/fixture/art-direction') + await expect(page.getByRole('heading', { name: 'Art-directed hero' })).toBeVisible() + const image = page.getByRole('img', { name: 'Viewport crop' }) + await decode(image) + const box = await image.boundingBox() + assert(box) + expect(box.width / box.height).toBeCloseTo(width === 390 ? 9 / 16 : 16 / 9, 2) + const requests = cdn.requests.slice(offset) + expect(requests).toHaveLength(1) + assert(requests[0]) + const query = new URL(requests[0].url).searchParams + expect(query.get('r')).toBe('fillcrop') + expect(query.get('w')).toBe(width === 390 ? '640' : '960') + expect(query.get('h')).toBe(width === 390 ? '1138' : '540') + // Native dimensions are density-corrected for the selected CSS slot; the audit separately + // decodes the downloaded bytes and checks their actual 640×1138 / 960×540 pixels. + await expect(image).toHaveJSProperty('naturalWidth', width === 390 ? 390 : 960) + await expect(image).toHaveJSProperty('naturalHeight', width === 390 ? 693 : 540) + await info.attach('art-directed-hero', { + body: await page.screenshot(), + contentType: 'image/png', + }) + }) +} + +for (const delivery of ['direct', 'redirect']) { + for (const width of [1200, 390]) { + test(`${delivery} hero and avatar decode and hydrate at ${width}px`, async ({ + browserName, + page, + }, info) => { + await page.setViewportSize({ width, height: 1000 }) + const afterHero = page.getByText('After the hero', { exact: true }) + const afterAvatar = page.getByText('After the avatar', { exact: true }) + let decodedGeometry: + | { + hero: Awaited> + avatar: Awaited> + } + | undefined + // Required pre-JS proof covers native private redirects in both browsers. Chromium also + // covers direct streaming; held bundles can stall React's reveal animation frame in WebKit. + const holdScripts = delivery === 'redirect' || browserName === 'chromium' + const scripts = Promise.withResolvers() + let scriptsWaiting = 0 + if (holdScripts) { + await page.route('**/_next/**/*.js*', async (route) => { + scriptsWaiting += 1 + await scripts.promise + await route.continue() + }) + } + const started = performance.now() + const requestOffset = cdn.requests.length + const hero = page.getByRole('img', { + name: delivery === 'direct' ? 'Storage hero' : 'Private hero', + exact: true, + }) + const avatar = page.getByRole('img', { + name: delivery === 'direct' ? 'Storage avatar' : 'Private avatar', + exact: true, + }) + try { + await page.goto(delivery === 'direct' ? '/fixture/storage-image' : '/fixture/browser', { + waitUntil: 'commit', + }) + await decode(hero) + await decode(avatar) + if (holdScripts) expect(scriptsWaiting).toBeGreaterThan(0) + decodedGeometry = { + hero: await afterHero.boundingBox(), + avatar: await afterAvatar.boundingBox(), + } + expect((await hero.boundingBox())?.width).toBe(Math.min(960, width - 16)) + expect((await avatar.boundingBox())?.width).toBe(48) + expect((await avatar.boundingBox())?.height).toBe(48) + const currentSrc = await hero.evaluate((element) => { + if (!(element instanceof HTMLImageElement)) throw new Error('Expected image') + return element.currentSrc + }) + const heroRequests = cdn.requests + .slice(requestOffset) + .filter((request) => + decodeURIComponent(new URL(request.url).pathname).endsWith('/documents/hero.jpg'), + ) + expect(heroRequests).toHaveLength(1) + const heroRequest = heroRequests[0] + assert(heroRequest) + expect(new URL(heroRequest.url).searchParams.get('w')).toBe(width === 1200 ? '960' : '640') + const heroSources = await hero.evaluate((element) => + [...(element.parentElement?.querySelectorAll('source') ?? [])].map( + (source) => source.srcset, + ), + ) + expect(heroSources.every((source) => !source.includes('2400w'))).toBe(true) + const avatarRequests = cdn.requests + .slice(requestOffset) + .filter((request) => + decodeURIComponent(new URL(request.url).pathname).endsWith('/documents/avatar.jpg'), + ) + expect(avatarRequests).toHaveLength(1) + assert(avatarRequests[0]) + expect(new URL(avatarRequests[0].url).searchParams.get('r')).toBe('fillcrop') + expect(new URL(avatarRequests[0].url).searchParams.get('w')).toBe('48') + expect(new URL(avatarRequests[0].url).searchParams.get('h')).toBe('48') + await expect(avatar).toHaveJSProperty('naturalWidth', 48) + await expect(avatar).toHaveJSProperty('naturalHeight', 48) + // WebKit has no CDP compositor API; its ordinary screenshot waits for the deliberately + // held document load. Geometry is checked now in both engines; both capture after hydration. + if (browserName === 'chromium') + await info.attach('before-application-js', { + body: await captureBeforeJavaScript(page), + contentType: 'image/png', + }) + await info.attach('load-diagnostics', { + body: JSON.stringify({ + viewportWidth: width, + imageReadyMs: performance.now() - started, + currentSrc, + browser: await page.evaluate(() => ({ + lcpMs: window.fixtureLcpMs, + navigation: performance.getEntriesByType('navigation')[0]?.toJSON(), + })), + }), + contentType: 'application/json', + }) + } finally { + scripts.resolve() + } + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + await info.attach('hydrated', { body: await page.screenshot(), contentType: 'image/png' }) + if (delivery === 'direct' && process.env.IMG_FIXTURE_CACHE_COMPONENTS === 'enabled') { + assert(decodedGeometry) + const hydrated = { + hero: await afterHero.boundingBox(), + avatar: await afterAvatar.boundingBox(), + } + const shell = await readFile('.next/server/app/storage-image.html', 'utf8') + expect(shell).not.toContain('builtin%2Fstorage-preview') + // Use this window only after the real page is finished: fake bootstrap responses must + // not contaminate its module cache, and extra windows can disturb WebKit rendering. + await page.route('**/fixture/storage-image', (route) => + route.fulfill({ contentType: 'text/html', body: shell }), + ) + const emptyScript = (route: Route): Promise => + route.fulfill({ + contentType: 'text/javascript', + body: '', + headers: { 'Cache-Control': 'no-store' }, + }) + await page.route('**/_next/**/*.js*', emptyScript) + const requestsBeforeShell = cdn.requests.length + await page.goto('/fixture/storage-image') + const pending = { + hero: await afterHero.boundingBox(), + avatar: await afterAvatar.boundingBox(), + } + expectSameBox(decodedGeometry.hero, pending.hero) + expectSameBox(decodedGeometry.avatar, pending.avatar) + expectSameBox(hydrated.hero, pending.hero) + expectSameBox(hydrated.avatar, pending.avatar) + expect(cdn.requests).toHaveLength(requestsBeforeShell) + await info.attach('prerendered-shell', { + body: await page.screenshot(), + contentType: 'image/png', + }) + } + }) + } +} + +test.describe('JPEG fallback', () => { + test.use({ javaScriptEnabled: false }) + test('native fallback decodes without modern sources and respects the candidate widths', async ({ + page, + }) => { + await page.route('**/fixture/browser', async (route) => { + const response = await route.fetch() + expect(response.ok()).toBe(true) + // Simulate unsupported picture sources before parsing. This is format fallback, not + // recovery from an HTTP failure; no hydration may restore the original source attributes. + const html = (await response.text()) + .replaceAll(/]*rel="(?:preload|modulepreload)")[^>]*>/g, '') + await route.fulfill({ response, body: html, headers: { ...response.headers(), link: '' } }) + }) + const requestOffset = cdn.requests.length + await page.goto('/fixture/browser') + await decode(page.getByRole('img', { name: 'Private hero', exact: true })) + const avatar = page.getByRole('img', { name: 'Private avatar', exact: true }) + await decode(avatar) + await decode(page.getByRole('img', { name: 'Late private preview' })) + await expect(avatar).toHaveJSProperty('naturalWidth', 48) + expect((await avatar.boundingBox())?.width).toBe(48) + const images = cdn.requests.slice(requestOffset).map((request) => new URL(request.url)) + // Browsers disable native lazy loading when JavaScript is disabled. + expect(images).toHaveLength(3) + expect(images.every((url) => (url.searchParams.get('f') ?? 'jpg') === 'jpg')).toBe(true) + }) +}) + +test('an opted-in fallback replaces a denied private image without leaking its credentials', async ({ + page, + context, + audit, +}) => { + await context.clearCookies() + await page.route('**/api/browser-images?*', async (route) => { + audit.expectedFailures.set(route.request().url(), 404) + await route.continue() + }) + await page.goto('/fixture/image-error') + await expect(page.getByRole('status')).toHaveText('Sign in to see this image') + await expect(page.getByRole('img', { name: 'Private preview' })).toHaveCount(0) + expect(await page.content()).not.toContain(imageConfiguration.authSecret) + await expect(page.getByRole('button', { name: 'Hydration count: 0' })).toBeVisible() + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + const refreshed = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname === '/fixture/image-error' && url.searchParams.has('_rsc') + }) + await page.getByRole('button', { name: 'Sign in and refresh' }).click() + expect((await refreshed).ok()).toBe(true) + // Flight can stay open after React commits. Verify the completed user interaction below, + // without buffering or waiting for EOF on the framework's streaming response. + await expect(page.getByRole('button', { name: 'Sign in and refresh' })).toBeEnabled() + await decode(page.getByRole('img', { name: 'Private preview' })) + await expect(page.getByRole('status')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + audit.committedRefreshes.add((await refreshed).request()) +}) + +test('the public catalog hero has stock-CSS geometry and no application image requests', async ({ + page, +}) => { + const applicationImages: string[] = [] + page.on('request', (request) => { + if (request.resourceType() === 'image' && request.url().includes('/api/')) + applicationImages.push(request.url()) + }) + await page.goto('/fixture/cli-image/app/storage-image-example') + const hero = page.getByRole('img', { name: 'hero', exact: true }) + await decode(hero) + const viewport = page.viewportSize() + if (viewport === null) throw new Error('Expected a fixed viewport') + expect((await hero.boundingBox())?.width).toBe(Math.min(960, viewport.width - 16)) + expect(applicationImages).toEqual([]) + expect(await hero.getAttribute('src')).toContain(cdnOrigin) + expect(await hero.getAttribute('src')).not.toMatch(/auth_key=|sig=|exp=/) + await expect(hero).toHaveAttribute('loading', 'eager') + await expect(hero).toHaveAttribute('fetchpriority', 'high') +}) + +test('package imports render public and private images with the conventional cookie-authorized handler', async ({ + page, + context, +}) => { + await page.goto('/fixture/package-images') + await expect(page.getByRole('heading', { name: 'Images from the package' })).toBeVisible() + const hero = page.getByRole('img', { name: 'Package hero', exact: true }) + const privateImage = page.getByRole('img', { name: 'Package private image', exact: true }) + await decode(hero) + await decode(privateImage) + expect(await hero.getAttribute('src')).toContain(cdnOrigin) + expect(await hero.getAttribute('src')).not.toMatch(/auth_key=|sig=|exp=/) + const privateUrl = await privateImage.getAttribute('src') + assert(privateUrl) + expect(privateUrl).toMatch(/^\/fixture\/api\/storage-images\?cap=/) + const granted = await context.request.head(privateUrl, { maxRedirects: 0 }) + expect(granted.status()).toBe(307) + expect(await granted.body()).toHaveLength(0) + await context.clearCookies() + expect((await context.request.get(privateUrl, { maxRedirects: 0 })).status()).toBe(404) + expect((await context.request.head(privateUrl, { maxRedirects: 0 })).status()).toBe(404) + await expect(page.getByRole('button', { name: 'Hydration count: 0' })).toBeVisible() + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() +}) + +if (process.env.IMG_FIXTURE_MODE === 'development') { + test('development scaffold does not blame sizes when a cached desktop candidate is reused on mobile', async ({ + page, + }) => { + const warnings: string[] = [] + page.on('console', (message) => { + if (message.type() === 'warning' && message.text().includes('[StorageImage]')) + warnings.push(message.text()) + }) + await page.setViewportSize({ width: 1200, height: 850 }) + await page.goto('/fixture/cli-image/app/storage-image-example') + const hero = page.getByRole('img', { name: 'hero', exact: true }) + await decode(hero) + await page.setViewportSize({ width: 390, height: 850 }) + await expect.poll(async () => (await hero.boundingBox())?.width).toBe(374) + // Let native source selection finish before reload can cancel the mobile candidate. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) + await decode(hero) + await page.reload() + await decode(hero) + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) + expect(warnings).toEqual([]) + }) + for (const viewportWidth of [390, 1200]) { + test(`development scaffold does not warn that its untouched image is oversized at ${viewportWidth}px`, async ({ + page, + }) => { + const warnings: string[] = [] + page.on('console', (message) => { + if (message.type() === 'warning' && message.text().includes('[StorageImage]')) + warnings.push(message.text()) + }) + await page.setViewportSize({ width: viewportWidth, height: 850 }) + await page.goto('/fixture/cli-image/app/storage-image-example') + const hero = page.getByRole('img', { name: 'hero', exact: true }) + await decode(hero) + expect((await hero.boundingBox())?.width).toBe(Math.min(960, viewportWidth - 16)) + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) + expect(warnings).toEqual([]) + }) + } + test('development scaffold waits for its real box after a temporary 1px layout', async ({ + page, + }) => { + const warnings: string[] = [] + page.on('console', (message) => { + if (message.type() === 'warning' && message.text().includes('[StorageImage]')) + warnings.push(message.text()) + }) + await page.goto('/fixture/cli-image/app/storage-image-example') + const hero = page.getByRole('img', { name: 'hero', exact: true }) + await decode(hero) + await page.getByRole('button', { name: 'Hydration count: 0' }).click() + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + // Change only layout: rewriting the dev HTML caused WebKit to reload and cancel resources. + // Pre-layout scheduling is covered by unit tests; this proves native resize observation. + const pendingLayout = await page.addStyleTag({ + content: 'picture{display:block;width:1px}', + }) + await expect.poll(async () => (await hero.boundingBox())?.width).toBe(1) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) + expect(warnings).toEqual([]) + await pendingLayout.evaluate((element) => element.parentNode?.removeChild(element)) + await expect.poll(async () => (await hero.boundingBox())?.width).toBe(960) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ) + await expect(page.getByRole('button', { name: 'Hydration count: 1' })).toBeVisible() + expect(warnings).toEqual([]) + }) +} + +test('the generated development scaffold shows a delivery failure instead of a blank page', async ({ + page, + audit, +}) => { + await page.route(`${cdnOrigin}/file/**`, (route) => { + audit.expectedFailures.set(route.request().url(), 400) + return route.fulfill({ status: 400, contentType: 'application/json', body: '{}' }) + }) + await page.goto('/fixture/cli-image/app/storage-image-example') + await expect( + page.getByRole('status').filter({ hasText: 'This image could not be loaded.' }), + ).toHaveText('This image could not be loaded. Check the Storage path and delivery configuration.') + await expect(page.getByRole('img', { name: 'hero', exact: true })).toHaveCount(0) + if (process.env.IMG_FIXTURE_MODE === 'development') { + // The browser-only failure stub does not affect the server's successful HEAD. Show that + // precise result, rather than claiming to know why the browser request failed. + const result = page.getByText(/^HEAD http.*HTTP 200\. See the terminal for details\.$/) + await expect(result).toBeVisible() + expect(await result.textContent()).not.toMatch(/[?#]|sig=|auth_key=|fixture-secret/) + } else { + await expect(page.getByText('See the terminal for details.', { exact: false })).toHaveCount(0) + } +}) + +for (const viewportWidth of [390, 1200]) { + test(`a small public original stays within its native width at ${viewportWidth}px`, async ({ + page, + audit, + }) => { + await page.setViewportSize({ width: viewportWidth, height: 850 }) + await page.goto('/fixture/public-image') + const image = page.getByRole('img', { name: 'Small public original', exact: true }) + await decode(image) + expect((await image.boundingBox())?.width).toBe(320) + expect((await image.boundingBox())?.height).toBe(240) + await expect + .poll(() => + audit.images.find((image) => + decodeURIComponent(new URL(image.url).pathname).endsWith('/website/small.jpg'), + ), + ) + .toMatchObject({ width: 320, height: 240 }) + }) +} + +test('the generated empty catalog page works before the first upload', async ({ page }) => { + await page.goto('/fixture/cli-empty/app/storage-image-example') + await expect( + page.getByText('npx transloadit storage store ./hero.jpg website/hero.jpg', { + exact: true, + }), + ).toBeVisible() + await expect(page.getByRole('img')).toHaveCount(0) +}) + +test('unsigned public Built-ins refuse private paths and private Built-ins never downgrade', async ({ + context, +}) => { + const publicUrl = new URL( + `${cdnOrigin}/file/fixture/builtin%2Fpublic-preview%400.0.1/website%2Fhero.jpg?w=400&h=300&r=pad&f=webp&bg=%2300000000&v=d41d8cd98f00b204`, + ) + expect((await context.request.get(publicUrl.href)).status()).toBe(200) + expect((await context.request.head(publicUrl.href)).headers()['cache-control']).toContain( + 'immutable', + ) + publicUrl.searchParams.set('sig', 'sha256:invalid') + expect((await context.request.get(publicUrl.href)).status()).toBe(403) + publicUrl.searchParams.delete('sig') + publicUrl.searchParams.delete('v') + expect((await context.request.head(publicUrl.href)).headers()['cache-control']).toBe( + 'public, max-age=259200, s-maxage=86400', + ) + publicUrl.pathname = + '/file/fixture/builtin%2Fpublic-preview%400.0.1/documents%2Fprivate%2Fhero.jpg' + expect((await context.request.get(publicUrl.href)).status()).toBe(403) + publicUrl.pathname = '/file/fixture/builtin%2Fstorage-preview%400.0.2/website%2Fhero.jpg' + expect((await context.request.get(publicUrl.href)).status()).toBe(403) +}) + +for (const width of [390, 1200]) { + test(`the packed hashed upload decodes at ${width}px with its catalog identity`, async ({ + page, + }, info) => { + const { path, receipt, assemblies } = JSON.parse(await readFile('hashed-upload.json', 'utf8')) + expect(assemblies).toBe(1) + await page.setViewportSize({ width, height: 850 }) + await page.goto('/fixture/package-hashed') + const image = page.getByRole('img', { name: 'Content-addressed hero', exact: true }) + await decode(image) + const current = await image.evaluate((element) => { + if (!(element instanceof HTMLImageElement)) throw new Error('Expected the hashed image') + return element.currentSrc + }) + const url = new URL(current) + expect(decodeURIComponent(url.pathname)).toBe( + `/file/fixture/builtin/public-preview@0.0.1/${path}`, + ) + expect(url.searchParams.get('v')).toBe(receipt.md5hash.slice(0, 16)) + expect(url.searchParams.has('sig')).toBe(false) + await info.attach('hashed-upload', { + body: JSON.stringify({ + path, + receipt, + assemblies, + currentSrc: current, + viewport: { width, height: 850 }, + }), + contentType: 'application/json', + }) + await info.attach('hashed-image', { body: await page.screenshot(), contentType: 'image/png' }) + }) +} + +test.describe('server-only blur placeholders', () => { + test.use({ javaScriptEnabled: false }) + + test('the packaged public image embeds its blur before native delivery', async ({ + page, + }, testInfo) => { + const delivery = Promise.withResolvers() + await page.route(`${cdnOrigin}/**`, async (route) => { + await delivery.promise + await route.continue() + }) + try { + await page.goto('/fixture/package-public', { waitUntil: 'domcontentloaded' }) + const image = page.getByRole('img', { name: 'Package public hero', exact: true }) + await expect(image).toHaveCSS('background-image', /^url\("data:image\/png;base64,/) + await expect(image).not.toHaveAttribute('onload') + await testInfo.attach('blur-before-load', { + // Screenshots await document.fonts.ready, which can await load while this image is held. + body: JSON.stringify( + await image.evaluate((element) => ({ + background: getComputedStyle(element).backgroundImage, + width: element.getBoundingClientRect().width, + height: element.getBoundingClientRect().height, + })), + ), + contentType: 'application/json', + }) + delivery.resolve() + await decode(image) + await expect(image).toHaveCSS('background-image', /^url\("data:image\/png;base64,/) + await testInfo.attach('blur-after-load', { + body: await page.screenshot(), + contentType: 'image/png', + }) + } finally { + delivery.resolve() + } + }) + + test('a transparent public image never paints a blur behind its alpha pixels', async ({ + page, + audit, + }, testInfo) => { + await page.goto('/fixture/package-alpha') + const image = page.getByRole('img', { name: 'Transparent public image', exact: true }) + await decode(image) + await expect(image).toHaveCSS('background-image', 'none') + await expect(image).not.toHaveAttribute('onload') + await expect + .poll( + () => + audit.images.find((entry) => + decodeURIComponent(new URL(entry.url).pathname).endsWith('/website/alpha.png'), + )?.corner?.[3], + ) + .toBe(0) + const letterbox = page.getByRole('img', { name: 'Letterboxed public image', exact: true }) + await decode(letterbox) + await expect(letterbox).toHaveCSS('background-image', 'none') + await expect(letterbox).toHaveCSS('object-fit', 'contain') + const letterboxPixels = await sharp(await letterbox.screenshot()) + .extract({ left: 150, top: 10, width: 1, height: 1 }) + .removeAlpha() + .raw() + .toBuffer() + expect([...letterboxPixels]).toEqual([255, 255, 255]) + await testInfo.attach('transparent-no-blur', { + body: await page.screenshot(), + contentType: 'image/png', + }) + }) +}) + +test('short public AVIF, WebP and JPEG candidates all decode natively', async ({ + page, + context, +}) => { + await page.goto('/fixture/package-public') + const image = page.getByRole('img', { name: 'Package public hero', exact: true }) + await decode(image) + const picture = image.locator('..') + const jpeg = await image.getAttribute('src') + const avif = (await picture.locator('source[type="image/avif"]').getAttribute('srcset'))?.split( + ' ', + )[0] + const webp = (await picture.locator('source[type="image/webp"]').getAttribute('srcset'))?.split( + ' ', + )[0] + for (const [candidate, mime] of [ + [avif, 'image/avif'], + [webp, 'image/webp'], + [jpeg, 'image/jpeg'], + ]) { + assert(candidate) + const url = new URL(candidate) + expect(url.searchParams.has('r')).toBe(false) + expect(url.searchParams.has('cdn')).toBe(false) + if (mime === 'image/jpeg') { + expect(url.searchParams.has('f')).toBe(false) + expect(url.searchParams.has('bg')).toBe(false) + expect(url.searchParams.has('q')).toBe(false) + } + const response = await context.request.get(url.href) + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toBe(mime) + const decoded = await page.evaluate(async (url) => { + const candidate = new Image() + candidate.src = url + await candidate.decode() + return { width: candidate.naturalWidth, height: candidate.naturalHeight } + }, url.href) + expect(decoded.width).toBe(Number(url.searchParams.get('w'))) + expect(decoded.height).toBe(Number(url.searchParams.get('h'))) + } +}) + +test('a portrait fill layout downloads the cropped box rather than an oversized landscape', async ({ + page, +}) => { + const requestOffset = cdn.requests.length + await page.goto('/fixture/layouts') + const image = page.getByRole('img', { name: 'Portrait cover' }) + await decode(image) + expect((await image.boundingBox())?.width).toBe(390) + expect((await image.boundingBox())?.height).toBeCloseTo((390 * 16) / 9, 1) + const requests = cdn.requests.slice(requestOffset) + expect(requests).toHaveLength(1) + assert(requests[0]) + const parameters = new URL(requests[0].url).searchParams + expect(parameters.get('r')).toBe('fillcrop') + expect(parameters.get('w')).toBe('390') + expect(parameters.get('h')).toBe('693') + await expect(image).toHaveJSProperty('naturalWidth', 390) + await expect(image).toHaveJSProperty('naturalHeight', 693) +}) + +test('an original lazy capability gets a new grant after its earlier target expires', async ({ + page, + audit, +}) => { + await page.goto('/fixture/browser') + await decode(page.getByRole('img', { name: 'Private hero', exact: true })) + const lazy = page.getByRole('img', { name: 'Late private preview' }) + const candidate = await lazy.evaluate( + (image) => image.parentElement?.querySelector('source')?.srcset.split(' ')[0], + ) + assert(candidate) + const capability = new URL(candidate, page.url()).href + const initial = await audit.loadNativeImage(capability) + expect(initial.loaded).toBe(true) + expect(initial.status).toBe(307) + const oldTarget = initial.headers.location + assert(oldTarget) + await expect(lazy).toHaveJSProperty('naturalWidth', 0) + await waitForExpiry(oldTarget) + const renewal = redirectResponse(page, capability) + await lazy.scrollIntoViewIfNeeded() + const renewed = await renewal + await decode(lazy) + expect(renewed.status()).toBe(307) + const target = renewed.headers().location + assert(target) + expect(Number(new URL(target).searchParams.get('exp'))).toBeGreaterThan( + Number(new URL(oldTarget).searchParams.get('exp')), + ) + expect( + await lazy.evaluate((image) => (image instanceof HTMLImageElement ? image.currentSrc : null)), + ).toBe(capability) +}) + +test('revocation denies new grants but an issued CDN target works until its own expiry', async ({ + page, + audit, +}) => { + const initial = page.waitForResponse( + (response) => response.status() === 307 && response.url().includes('/api/browser-images'), + ) + await page.goto('/fixture/browser') + await decode(page.getByRole('img', { name: 'Private hero', exact: true })) + await decode(page.getByRole('img', { name: 'Private avatar', exact: true })) + const issued = await initial + const capability = issued.url() + const target = issued.headers().location + assert(target) + await writeFile(revokedAccessFile, 'revoked\n') + audit.expectedFailures.set(capability, 404) + const requestsBefore = cdn.requests.length + const denied = await audit.loadNativeImage(capability) + expect(denied.loaded).toBe(false) + expect(denied.status).toBe(404) + expect(denied.headers['cache-control']).toBe('private, no-store') + expect(denied.headers.location).toBeUndefined() + expect(denied.bodyLength).toBe(0) + expect(cdn.requests).toHaveLength(requestsBefore) + expect((await audit.loadNativeImage(target)).loaded).toBe(true) + await waitForExpiry(target) + audit.expectedFailures.set(target, 403) + expect((await audit.loadNativeImage(target)).loaded).toBe(false) + expect(cdn.requests.at(-1)?.status).toBe(403) +}) + +test('native requests cannot use altered capabilities or CDN signatures', async ({ + page, + audit, + context, +}) => { + const initial = page.waitForResponse( + (response) => response.status() === 307 && response.url().includes('/api/private-images'), + ) + await page.goto('/fixture/storage-redirect') + await decode(page.getByRole('img', { name: 'Authorized Storage fixture' })) + const issued = await initial + const altered = new URL(issued.url()) + const cap = altered.searchParams.get('cap') + assert(cap) + altered.searchParams.set('cap', `${cap[0] === 'A' ? 'B' : 'A'}${cap.slice(1)}`) + audit.expectedFailures.set(altered.href, 404) + expect((await audit.loadNativeImage(altered.href)).loaded).toBe(false) + const target = new URL(issued.headers().location ?? '') + target.searchParams.set('sig', `sha256:${'0'.repeat(64)}`) + audit.expectedFailures.set(target.href, 403) + expect((await audit.loadNativeImage(target.href)).loaded).toBe(false) + await context.clearCookies() + audit.expectedFailures.set(issued.url(), 404) + expect((await audit.loadNativeImage(issued.url())).loaded).toBe(false) +}) diff --git a/scripts/fixtures/img-next/catalog-types.tsx b/scripts/fixtures/img-next/catalog-types.tsx new file mode 100644 index 00000000..3c45aeed --- /dev/null +++ b/scripts/fixtures/img-next/catalog-types.tsx @@ -0,0 +1,12 @@ +import type { RegisteredStorageImages } from '@transloadit/img/next' + +import { StorageImage } from '@transloadit/img/next' + +const valid = +// @ts-expect-error The generated catalog rejects misspelled paths without a consumer factory. +const invalid = +// @ts-expect-error The generated intrinsic geometry retains exact numeric literals. +const wrongWidth: RegisteredStorageImages['website/hero.jpg']['width'] = 960 +void valid +void invalid +void wrongWidth diff --git a/scripts/fixtures/img-next/next.config.ts b/scripts/fixtures/img-next/next.config.ts index 70b9513e..2c89620c 100644 --- a/scripts/fixtures/img-next/next.config.ts +++ b/scripts/fixtures/img-next/next.config.ts @@ -1,5 +1,14 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = { basePath: '/fixture', cacheComponents: true } +import { withTransloaditImages } from '@transloadit/img/next/config' -export default nextConfig +const nextConfig: NextConfig = { + basePath: '/fixture', + // Keep the development badge out of consumer UI checks. Compile/runtime errors still surface. + devIndicators: false, + ...(process.env.IMG_FIXTURE_CACHE_COMPONENTS === 'enabled' ? { cacheComponents: true } : {}), +} + +export default withTransloaditImages(nextConfig, { + delivery: { baseUrl: `${process.env.IMG_FIXTURE_CDN_ORIGIN}/file/{workspace}` }, +}) diff --git a/scripts/fixtures/img-next/package-lock.json b/scripts/fixtures/img-next/package-lock.json index c148d2bd..114e4f79 100644 --- a/scripts/fixtures/img-next/package-lock.json +++ b/scripts/fixtures/img-next/package-lock.json @@ -7,18 +7,448 @@ "name": "transloadit-img-next-fixture", "dependencies": { "@noble/ciphers": "1.3.0", - "next": "16.3.0", + "next": "16.3.4", "react": "19.2.8", "react-dom": "19.2.8", - "server-only": "0.0.1" + "server-only": "0.0.1", + "thumbhash": "0.1.1" }, "devDependencies": { + "@aws-sdk/client-s3": "3.1073.0", + "@playwright/test": "1.58.2", + "@transloadit/node": "4.11.1", + "@transloadit/types": "4.3.4", "@types/node": "25.8.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", + "sharp": "0.35.4", "typescript": "6.0.3" } }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.0.tgz", + "integrity": "sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1073.0.tgz", + "integrity": "sha512-/Dvhrff0I4D2YUWSdm8uLKa1bfXdw9BMRDUME6ZeoTrrdQKQDeo2scLDjdpC5X2YdvTc/ZnUCR2HAvD7qXvS1w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-node": "^3.972.57", + "@aws-sdk/middleware-flexible-checksums": "^3.974.32", + "@aws-sdk/middleware-sdk-s3": "^3.972.53", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.55.tgz", + "integrity": "sha512-D8EDHpDU6AUFSJ2ltzGNfXpXBqJnfovOUbwAWCENGK9ro6LdrJd4dYAmaOfRa8dPs1QKLrDgUWiyMPX5RiaGrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.76.tgz", + "integrity": "sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -33,8 +463,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -578,16 +1008,23 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", "cpu": [ "arm64" ], @@ -601,9 +1038,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", "cpu": [ "x64" ], @@ -617,9 +1054,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", "cpu": [ "arm64" ], @@ -636,9 +1073,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", "cpu": [ "arm64" ], @@ -655,9 +1092,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", "cpu": [ "x64" ], @@ -674,9 +1111,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", "cpu": [ "x64" ], @@ -693,9 +1130,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", "cpu": [ "arm64" ], @@ -709,9 +1146,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", "cpu": [ "x64" ], @@ -736,100 +1173,1073 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "tslib": "^2.8.0" + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } + "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@types/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "node_modules/@smithy/core": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@transloadit/abbr": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@transloadit/abbr/-/abbr-1.0.0.tgz", + "integrity": "sha512-Hg5xdbpsDfUiUc62fIAF6L86+o52pY37/eKCOKqvJCJfSJ0ET6AG5FBIg6o2tTiFF7si5lhGIHONs0/IAEwc2Q==", + "dev": true, + "license": "AGPL-3.0-only" + }, + "node_modules/@transloadit/node": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@transloadit/node/-/node-4.11.1.tgz", + "integrity": "sha512-TS1O3G4fFj20ia3MwUfwTINgdlkmRWSVQr5+AeLaKJkSpVSml4/OTat3fqi4gbW6w9a+Fu0kq5bAJ5jZEnvR8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@transloadit/sev-logger": "^0.1.9", + "@transloadit/utils": "^4.4.1", + "cacheable-lookup": "^7.0.0", + "clipanion": "^4.0.0-rc.4", + "debug": "^4.4.3", + "dotenv": "^17.4.2", + "form-data": "^4.0.5", + "got": "14.6.6", + "into-stream": "^9.1.0", + "is-stream": "^4.0.1", + "json-to-ast": "^2.1.0", + "lodash-es": "^4.18.1", + "node-watch": "^0.7.4", + "p-map": "^7.0.4", + "p-queue": "^9.3.0", + "recursive-readdir": "^2.2.3", + "tus-js-client": "^4.3.1", + "typanion": "^3.14.0", + "type-fest": "^5.6.0", + "zod": "3.25.76" + }, + "bin": { + "transloadit": "dist/cli.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@transloadit/sev-logger": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@transloadit/sev-logger/-/sev-logger-0.1.9.tgz", + "integrity": "sha512-TALqS5mOo+5TmwNdtRfsfOhtjhfCuXllVffNoiGEpewmbwsxBfrTdcE/9/Ayst9rfT0HST0KU1l71lxltLMEwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@transloadit/abbr": "^1.0.0" + } + }, + "node_modules/@transloadit/types": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@transloadit/types/-/types-4.3.4.tgz", + "integrity": "sha512-pmp3wo1bPIG+MwuZGatVMCsdvXUfy9nx+58dC8S30S8eb7+8H7WBCoBvuZUjajclx3uacj8TlhSXSZE660DBPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@transloadit/utils": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@transloadit/utils/-/utils-4.8.0.tgz", + "integrity": "sha512-lNe9zKj51CeN1WpzSsGatCIPaQItudlbc6SgWzTc0jPnWUqf4hHJChAL2MfgOZ75zWz2st/S/jnKCtcZDImG6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/byte-counter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", + "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.19", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz", + "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.2.0", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.6.0", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.1", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/code-error-fragment": { + "version": "0.0.230", + "resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz", + "integrity": "sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/combine-errors": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", + "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==", + "dev": true, + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", + "dev": true, + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", + "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz", + "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^7.0.1", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.12", + "decompress-response": "^10.0.0", + "form-data-encoder": "^4.0.2", + "http2-wrapper": "^2.2.1", + "keyv": "^5.5.3", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^4.26.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/into-stream": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-9.1.0.tgz", + "integrity": "sha512-DRsRnQrbzdFjaQ1oe4C6/EIUymIOEix1qROEJTF9dbMq+M4Zrm6VaLp6SD/B9IsiEjPZuBSnWWFN+udajugdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-base64": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz", + "integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/json-to-ast": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz", + "integrity": "sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-error-fragment": "0.0.230", + "grapheme-splitter": "^1.0.4" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -849,13 +2259,13 @@ } }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -868,15 +2278,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -901,12 +2311,120 @@ } } }, + "node_modules/node-watch": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.4.tgz", + "integrity": "sha512-RinNxoz4W1cep1b928fuFhvAQ5ag/+1UlMDV7rbyGthBIgsiEouS4kvRayvvboxii4m8eolKOIBo3OjDqbc+uQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -935,6 +2453,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -956,6 +2506,59 @@ "react": "^19.2.8" } }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -966,8 +2569,8 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -985,8 +2588,8 @@ "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -1031,6 +2634,13 @@ } } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1063,12 +2673,89 @@ } } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thumbhash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz", + "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tus-js-client": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tus-js-client/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1089,6 +2776,27 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/scripts/fixtures/img-next/package.json b/scripts/fixtures/img-next/package.json index c113c37d..20a50488 100644 --- a/scripts/fixtures/img-next/package.json +++ b/scripts/fixtures/img-next/package.json @@ -7,15 +7,21 @@ }, "dependencies": { "@noble/ciphers": "1.3.0", - "next": "16.3.0", + "next": "16.3.4", "react": "19.2.8", "react-dom": "19.2.8", - "server-only": "0.0.1" + "server-only": "0.0.1", + "thumbhash": "0.1.1" }, "devDependencies": { + "@aws-sdk/client-s3": "3.1073.0", + "@playwright/test": "1.58.2", + "@transloadit/node": "4.11.1", + "@transloadit/types": "4.3.4", "@types/node": "25.8.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", + "sharp": "0.35.4", "typescript": "6.0.3" } } diff --git a/scripts/fixtures/img-next/playwright.config.ts b/scripts/fixtures/img-next/playwright.config.ts new file mode 100644 index 00000000..5607476c --- /dev/null +++ b/scripts/fixtures/img-next/playwright.config.ts @@ -0,0 +1,24 @@ +import { resolve } from 'node:path' + +import { defineConfig } from '@playwright/test' + +const outputDir = process.env.IMG_FIXTURE_OUTPUT_DIR ?? 'test-results' + +export default defineConfig({ + forbidOnly: true, + outputDir, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], + reporter: [['list'], ['json', { outputFile: resolve(outputDir, 'results.json') }]], + retries: 0, + testMatch: 'browser.spec.ts', + timeout: 45_000, + use: { + baseURL: process.env.IMG_FIXTURE_BASE_URL, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + workers: 1, +}) diff --git a/scripts/fixtures/img-next/seed.test.ts b/scripts/fixtures/img-next/seed.test.ts new file mode 100644 index 00000000..cb31c591 --- /dev/null +++ b/scripts/fixtures/img-next/seed.test.ts @@ -0,0 +1,463 @@ +import type { AssemblyStatus } from '@transloadit/node' +import type { InterpolatableRobotTransloaditStoreInstructions } from '@transloadit/types/robots' + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { createTransloaditImageModel } from '@transloadit/img' +import { Transloadit } from '@transloadit/node' +import sharp from 'sharp' +import { rgbaToThumbHash } from 'thumbhash' + +import { seedStorageImage } from './seed.ts' + +// Real local pixels keep the seed/ThumbHash recipe offline; the devdock canary uses real API2. +const bytes = await sharp({ create: { width: 1, height: 1, channels: 4, background: '#2d6ea0' } }) + .png() + .toBuffer() +const receipt = { + asset_id: 'JN6OawlqFmL419U23jUKcg', + md5hash: createHash('md5').update(bytes).digest('hex'), + meta: { height: 1, width: 1 }, + path: 'website/photo.png', + size: bytes.length, +} + +test('the package-first path stores and publishes without image init and emits catalog augmentation', async (t) => { + const loginDirectory = await mkdtemp(join(tmpdir(), 'img-package-login-')) + t.after(() => rm(loginDirectory, { recursive: true, force: true })) + const credentials = join(loginDirectory, 'credentials') + await writeFile( + credentials, + 'TRANSLOADIT_KEY=assembly-key\nTRANSLOADIT_SECRET=assembly-secret\nTRANSLOADIT_WORKSPACE=fixture\nTRANSLOADIT_WORKSPACE_VERIFIED=true\n', + { mode: 0o600 }, + ) + const environment = { ...process.env } + t.after(() => { + process.env = environment + process.exitCode = undefined + }) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + ]) + delete process.env[name] + process.env.TRANSLOADIT_CREDENTIALS_FILE = credentials + t.mock.method(process.stdout, 'write', () => true) + t.mock.method(Transloadit.prototype, 'publishStoragePrefix', async (prefix: string) => ({ + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix, + created_at: '2026-09-14', + created: false, + })) + t.mock.method( + Transloadit.prototype, + 'storeImage', + async (_file: string, { path }: { path: string }) => ({ + asset_id: 'fixture-asset', + path, + size: bytes.length, + md5hash: receipt.md5hash, + width: path === 'website/hero.jpg' ? 2400 : path === 'website/alpha.png' ? 64 : 400, + height: path === 'website/hero.jpg' ? 1600 : path === 'website/alpha.png' ? 64 : 300, + thumbhash: Buffer.from(rgbaToThumbHash(1, 1, [45, 110, 160, 255])).toString('base64'), + ...(path === 'website/alpha.png' ? { hasAlpha: true } : {}), + }), + ) + const cli: { main: (args: string[]) => Promise } = await import( + new URL('./cli.js', import.meta.resolve('@transloadit/node')).href + ) + await cli.main(['storage', 'store', './hero.jpg', 'website/hero.jpg', '--public']) + assert.equal(process.exitCode, undefined) + await cli.main(['storage', 'store', './avatar.jpg', 'documents/private/hero.jpg']) + assert.equal(process.exitCode, undefined) + await cli.main(['storage', 'store', './avatar.jpg', 'accounts/avatar.jpg']) + assert.equal(process.exitCode, undefined) + await cli.main(['storage', 'store', './hero.jpg', 'website/alpha.png']) + assert.equal(process.exitCode, undefined) + await cli.main(['storage', 'publish', 'documents/public/']) + assert.equal(process.exitCode, undefined) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + assert.deepEqual(catalog.public, ['website/', 'documents/public/']) + const declarations = await readFile('transloadit-images.d.ts', 'utf8') + assert(declarations.includes("declare module '@transloadit/img/next'")) + assert( + declarations.includes( + '"website/hero.jpg": { path: "website/hero.jpg"; width: 2400; height: 1600; thumbhash?: string; hasAlpha?: boolean }', + ), + ) + await assert.rejects(stat('lib/storageImage.ts'), { code: 'ENOENT' }) +}) + +test('the packed CLI stores a hashed image once and renders its exact typed path', async (t) => { + const originalCwd = process.cwd() + const directory = await mkdtemp(join(tmpdir(), 'img-hashed-app-')) + t.after(async () => { + process.chdir(originalCwd) + await rm(directory, { recursive: true, force: true }) + }) + // Each app gets its own CLI environment snapshot, just as a fresh CLI process would. + await cp(join(originalCwd, 'transloadit.images.json'), join(directory, 'transloadit.images.json')) + process.chdir(directory) + const loginDirectory = await mkdtemp(join(tmpdir(), 'img-hashed-login-')) + t.after(() => rm(loginDirectory, { recursive: true, force: true })) + const credentials = join(loginDirectory, 'credentials') + await writeFile( + credentials, + 'TRANSLOADIT_KEY=assembly-key\nTRANSLOADIT_SECRET=assembly-secret\nTRANSLOADIT_WORKSPACE=fixture\nTRANSLOADIT_WORKSPACE_VERIFIED=true\n', + { mode: 0o600 }, + ) + const previousEnv = { ...process.env } + t.after(() => { + process.env = previousEnv + process.exitCode = undefined + }) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + ]) + delete process.env[name] + process.env.TRANSLOADIT_CREDENTIALS_FILE = credentials + const local = await sharp({ + create: { width: 2400, height: 1600, channels: 3, background: '#2d6ea0' }, + }) + .jpeg() + .toBuffer() + const md5hash = createHash('md5').update(local).digest('hex') + const path = `website/hashed-hero.${md5hash.slice(0, 8)}.jpg` + const stored = { + ...receipt, + path, + md5hash, + size: local.length, + meta: { width: 2400, height: 1600 }, + } + await writeFile('hashed-hero.jpg', local) + const output: string[] = [] + t.mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => { + output.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()) + return true + }) + const create = t.mock.method( + Transloadit.prototype, + 'createAssembly', + (options: { + params?: { steps?: { stored?: { path?: string; conflict_strategy?: string } } } + }) => { + assert.equal(options.params?.steps?.stored?.path, path) + assert.equal(options.params?.steps?.stored?.conflict_strategy, 'error') + const response: AssemblyStatus = { + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [stored] }, + } + return Promise.resolve(response) + }, + ) + const cli: { main: (args: string[]) => Promise } = await import( + new URL('./cli.js', import.meta.resolve('@transloadit/node')).href + ) + const args = ['storage', 'store', './hashed-hero.jpg', 'website/', '--hashed'] + await cli.main(args) + assert.equal(process.exitCode, undefined) + const originalCatalog = await readFile('transloadit.images.json', 'utf8') + await cli.main(args) + assert.equal(process.exitCode, undefined) + assert.equal(create.mock.callCount(), 1) + assert.equal(await readFile('transloadit.images.json', 'utf8'), originalCatalog) + const catalog = JSON.parse(originalCatalog) + assert.equal(catalog.images[path].source, 'hashed-hero.jpg') + assert.equal(catalog.images[path].apiOrigin, 'https://api2.transloadit.com') + assert.equal(catalog.images[path].md5hash, md5hash) + assert.equal(catalog.images[path].path, path) + assert( + (await readFile('transloadit-images.d.ts', 'utf8')).includes( + `"${path}": { path: "${path}"; width: 2400; height: 1600;`, + ), + ) + assert(output.join('').includes(`\n}\n`, + ) + await writeFile( + 'hashed-upload.json', + `${JSON.stringify({ path, receipt: catalog.images[path], output, assemblies: create.mock.callCount() }, null, 2)}\n`, + ) +}) + +test('the packed CLI scaffolds an empty catalog and the actual constrained page used by the browser proof', async (t) => { + const originalCwd = process.cwd() + const directory = join(originalCwd, 'app/cli-image') + const loginDirectory = await mkdtemp(join(tmpdir(), 'img-fixture-login-')) + t.after(() => rm(loginDirectory, { recursive: true, force: true })) + const credentials = join(loginDirectory, 'credentials') + await writeFile( + credentials, + 'TRANSLOADIT_KEY=assembly-key\nTRANSLOADIT_SECRET=assembly-secret\nTRANSLOADIT_WORKSPACE=fixture\nTRANSLOADIT_WORKSPACE_VERIFIED=true\n', + { mode: 0o600 }, + ) + await mkdir(join(directory, 'app'), { recursive: true }) + const environment = { ...process.env } + t.after(() => { + process.chdir(originalCwd) + process.env = environment + process.exitCode = undefined + }) + process.chdir(directory) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + ]) + delete process.env[name] + process.env.TRANSLOADIT_CREDENTIALS_FILE = credentials + const cli: { main: (args: string[]) => Promise } = await import( + new URL('./cli.js', import.meta.resolve('@transloadit/node')).href + ) + const output: string[] = [] + t.mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => { + output.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()) + return true + }) + // Exercise the packed CLI and real receipt validation, including an older backend's + // pre-Storage transformation. Only the remote Assembly response is simulated here. + const stored = { + ...receipt, + path: 'website/hero.jpg', + size: bytes.length + 27, + md5hash: 'b'.repeat(32), + meta: { width: 2400, height: 1600 }, + } + const response: AssemblyStatus = { + assembly_id: 'fixture-transformed-upload', + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [stored] }, + } + t.mock.method(Transloadit.prototype, 'createAssembly', () => + Object.assign(Promise.resolve(response), { assemblyId: response.assembly_id }), + ) + t.mock.method(Transloadit.prototype, 'publishStoragePrefix', async () => ({ + ok: 'STORAGE_PUBLIC_PREFIX_DECLARED', + prefix: 'website/', + created_at: '2026-09-13', + created: true, + })) + await cli.main(['image', 'init', 'website', '--public']) + assert.equal(process.exitCode, undefined) + await assert.rejects(stat('.env.local'), { code: 'ENOENT' }) + assert.deepEqual(JSON.parse(await readFile('transloadit.images.json', 'utf8')), { + workspace: 'fixture', + public: ['website/'], + images: {}, + }) + // Keep the genuine post-init/pre-upload state in the Next build and browser matrix too. + await cp(directory, join(originalCwd, 'app/cli-empty'), { recursive: true }) + await writeFile('hero.jpg', bytes) + await cli.main(['storage', 'store', './hero.jpg', 'website/hero.jpg']) + assert.equal(process.exitCode, undefined) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + assert.equal(catalog.images[stored.path].md5hash, stored.md5hash) + assert.equal(catalog.images[stored.path].size, stored.size) + // Changed origin bytes deliberately have no local preview metadata to recommend. + assert.equal(catalog.images[stored.path].thumbhash, undefined) + const mixedCatalog = { + ...catalog, + images: { + 'accounts/avatar.jpg': { path: 'accounts/avatar.jpg', width: 200, height: 200 }, + ...catalog.images, + }, + } + // An unrelated private entry must not become the generated public example's first image. + await writeFile('transloadit.images.json', `${JSON.stringify(mixedCatalog)}\n`) + + const privateDirectory = join(originalCwd, 'app/cli-private') + await mkdir(join(privateDirectory, 'app'), { recursive: true }) + process.chdir(privateDirectory) + await writeFile('transloadit.images.json', `${JSON.stringify(mixedCatalog)}\n`) + await cli.main(['image', 'init', 'uploads/', '--private', '--example']) + assert.equal(process.exitCode, undefined) + // The conventional private example must import successfully with preserved public policy, and its + // example must stay empty when the catalog has images only outside uploads/. + process.chdir(directory) + const printed = output.join('') + const page = await readFile('app/storage-image-example/page.tsx', 'utf8') + assert.match(page, /width=\{960\}\s+preload/) + assert( + printed.includes( + 'Render it with ', + ), + ) + assert(!printed.includes('export default function Page')) + assert(page.includes("from '@transloadit/img/next'")) + await assert.rejects(stat('lib/storageImage.ts'), { code: 'ENOENT' }) + // The generated page is verbatim; the fixture's Next plugin supplies the local CDN origin. +}) + +test('a failed second run of the documented command preserves the first receipt', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'img-seed-receipt-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const previous = `${JSON.stringify({ ...receipt, width: 1, height: 1 })}\n` + await writeFile(join(directory, 'image.json'), previous) + const documentation = await readFile( + process.env.IMG_DOGFOOD_DOC ?? new URL('../../../docs/img-dogfood.md', import.meta.url), + 'utf8', + ) + const command = documentation.match( + /\n```bash\n([\s\S]*?)\n```/, + )?.[1] + assert(command, 'Expected the documented receipt command') + const result = spawnSync('bash', ['-c', command], { cwd: directory, encoding: 'utf8' }) + assert.notEqual(result.status, 0, 'Missing seed credentials must fail') + assert.equal(await readFile(join(directory, 'image.json'), 'utf8'), previous) +}) + +test('seeds one original and returns verified metadata for rendering without another lookup', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'img-seed-test-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const filePath = join(directory, 'photo.png') + await writeFile(filePath, bytes) + const client = new Transloadit({ + authKey: 'assembly-key', + authSecret: 'assembly-secret', + endpoint: 'http://127.0.0.1:9', + }) + const response: AssemblyStatus = { ok: 'ASSEMBLY_COMPLETED', results: { ':original': [receipt] } } + const create = t.mock.method(client, 'createAssembly', () => + Object.assign(Promise.resolve(response), { assemblyId: 'offline-assembly' }), + ) + const image = await seedStorageImage(client, filePath, receipt.path) + assert.deepEqual(image, { + asset_id: receipt.asset_id, + hasAlpha: true, + height: 1, + md5hash: receipt.md5hash, + path: receipt.path, + size: bytes.length, + width: 1, + thumbhash: Buffer.from(rgbaToThumbHash(1, 1, [45, 110, 160, 255])).toString('base64'), + }) + const model = createTransloaditImageModel( + { src: image, expiresAt: Date.UTC(2030, 0, 1) }, + ({ input }) => { + assert.equal(input, receipt.path) + return `https://cdn.example/${input}` + }, + ) + assert.equal(model.sources[0]?.candidates[0]?.width, image.width) + assert.deepEqual(create.mock.calls[0]?.arguments[0], { + chunkSize: undefined, + files: { image: filePath }, + onAssemblyProgress: undefined, + onUploadProgress: undefined, + params: { + steps: { + stored: { + robot: '/transloadit/store', + use: ':original', + path: receipt.path, + conflict_strategy: 'error', + } satisfies InterpolatableRobotTransloaditStoreInstructions, + }, + }, + signal: undefined, + timeout: undefined, + waitForCompletion: true, + }) +}) + +test('orients a phone-photo receipt before generating proportional preview candidates', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'img-seed-rotated-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const filePath = join(directory, 'rotated.jpg') + const rotated = await sharp({ + create: { width: 450, height: 600, channels: 3, background: { r: 100, g: 150, b: 200 } }, + }) + .withMetadata({ orientation: 6 }) + .jpeg() + .toBuffer() + await writeFile(filePath, rotated) + const { width, height, orientation } = await sharp(rotated).metadata() + assert.deepEqual({ width, height, orientation }, { width: 450, height: 600, orientation: 6 }) + const client = new Transloadit({ + authKey: 'assembly-key', + authSecret: 'assembly-secret', + endpoint: 'http://127.0.0.1:9', + }) + const path = 'website/rotated.jpg' + // Match API2's EXIFTool metadata, including the label in its rotated_8.jpg fixture. + const response: AssemblyStatus = { + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [ + { + ...receipt, + md5hash: createHash('md5').update(rotated).digest('hex'), + meta: { width, height, orientation: 'Rotate 90 CW' }, + path, + size: rotated.length, + }, + ], + }, + } + t.mock.method(client, 'createAssembly', () => + Object.assign(Promise.resolve(response), { assemblyId: 'offline-assembly' }), + ) + const image = await seedStorageImage(client, filePath, path) + assert.equal(image.width, 600) + assert.equal(image.height, 450) + const model = createTransloaditImageModel( + { src: image, expiresAt: Date.UTC(2030, 0, 1), widths: [320], formats: { webp: 75 } }, + ({ urlParams }) => `https://cdn.example/image?w=${urlParams?.w}&h=${urlParams?.h}`, + ) + assert.equal(model.sources[0]?.candidates[0]?.url, 'https://cdn.example/image?w=320&h=240') + assert.equal(model.fallbackUrl, 'https://cdn.example/image?w=320&h=240') + const { info } = await sharp(rotated) + .autoOrient() + .resize(320) + .webp() + .toBuffer({ resolveWithObject: true }) + assert.deepEqual({ width: info.width, height: info.height }, { width: 320, height: 240 }) +}) + +for (const missing of ['asset_id', 'path', 'size', 'md5hash', 'meta']) { + test(`rejects a seed receipt missing ${missing}`, async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'img-seed-test-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const filePath = join(directory, 'photo.png') + await writeFile(filePath, bytes) + const client = new Transloadit({ + authKey: 'assembly-key', + authSecret: 'assembly-secret', + endpoint: 'http://127.0.0.1:9', + }) + const response: AssemblyStatus = { + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [{ ...receipt, [missing]: undefined }] }, + } + t.mock.method(client, 'createAssembly', () => + Object.assign(Promise.resolve(response), { assemblyId: 'offline-assembly' }), + ) + await assert.rejects( + seedStorageImage(client, filePath, receipt.path), + /matching Storage image receipt/, + ) + }) +} diff --git a/scripts/fixtures/img-next/seed.ts b/scripts/fixtures/img-next/seed.ts new file mode 100644 index 00000000..e70fe2ca --- /dev/null +++ b/scripts/fixtures/img-next/seed.ts @@ -0,0 +1,37 @@ +import type { StoredImageReceipt } from '@transloadit/node' + +import { Transloadit } from '@transloadit/node' + +/** Seed with an Assembly key, then save the receipt for rendering without another lookup. */ +export function seedStorageImage( + client: Transloadit, + filePath: string, + path: string, +): Promise { + return client.storeImage(filePath, { path }) +} + +async function main(): Promise { + const authKey = process.env.TRANSLOADIT_ASSEMBLY_KEY + const authSecret = process.env.TRANSLOADIT_ASSEMBLY_SECRET + const [filePath, path] = process.argv.slice(2) + if (!authKey || !authSecret || !filePath || !path) { + throw new Error( + 'Provide an Assembly key/secret and run: node seed.ts ./image.jpg website/image.jpg', + ) + } + const client = new Transloadit({ + authKey, + authSecret, + signatureAlgorithm: 'sha256', + endpoint: process.env.TRANSLOADIT_ASSEMBLY_ENDPOINT, + }) + console.log(JSON.stringify(await seedStorageImage(client, filePath, path), null, 2)) +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/scripts/fixtures/img-next/transloadit.authorize.ts b/scripts/fixtures/img-next/transloadit.authorize.ts new file mode 100644 index 00000000..df7c6841 --- /dev/null +++ b/scripts/fixtures/img-next/transloadit.authorize.ts @@ -0,0 +1,6 @@ +import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server' + +import { authorizeFixtureImage } from './browser-policy.ts' + +/** Toy application authorization, exercised using the browser's real HttpOnly session cookie. */ +export const authorize: AuthorizeTransloaditStorageImage = authorizeFixtureImage diff --git a/scripts/fixtures/img-next/tsconfig.json b/scripts/fixtures/img-next/tsconfig.json index 39cdfd0c..c0784fe4 100644 --- a/scripts/fixtures/img-next/tsconfig.json +++ b/scripts/fixtures/img-next/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "allowImportingTsExtensions": true, "jsx": "preserve", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", @@ -8,5 +7,15 @@ "noEmit": true, "strict": true, "target": "ES2017" - } + }, + "include": [ + "next-env.d.ts", + "transloadit-images.d.ts", + "catalog-types.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "app/**/*.ts", + "app/**/*.tsx" + ], + "exclude": ["node_modules", "app/**/transloadit-images.d.ts"] } diff --git a/scripts/fixtures/img-next/tsconfig.tooling.json b/scripts/fixtures/img-next/tsconfig.tooling.json new file mode 100644 index 00000000..5df9f17c --- /dev/null +++ b/scripts/fixtures/img-next/tsconfig.tooling.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "allowImportingTsExtensions": true }, + "include": ["*.ts"] +} diff --git a/scripts/fixtures/img-next/untyped-consumer.tsx b/scripts/fixtures/img-next/untyped-consumer.tsx new file mode 100644 index 00000000..0028d69d --- /dev/null +++ b/scripts/fixtures/img-next/untyped-consumer.tsx @@ -0,0 +1,5 @@ +import { StorageImage } from '@transloadit/img/next' + +// Compiled in isolation without the generated declaration: runtime catalog geometry is enough. +const image = +void image diff --git a/scripts/img-next-fixture.test.ts b/scripts/img-next-fixture.test.ts index 2ed06ae2..b7405d64 100644 --- a/scripts/img-next-fixture.test.ts +++ b/scripts/img-next-fixture.test.ts @@ -1,17 +1,459 @@ -import { readFile } from 'node:fs/promises' +import type { Node } from 'typescript' + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { execa } from 'execa' +import { + createSourceFile, + forEachChild, + isStringLiteral, + ScriptKind, + ScriptTarget, +} from 'typescript' +import { expect, onTestFinished, test } from 'vitest' + +import { storageImagePage } from '../packages/node/src/cli/storageSnippets.ts' -import { expect, test } from 'vitest' +test('scaffold helpers load in a cold checkout without built workspace packages', async () => { + const source = pathToFileURL( + resolve(import.meta.dirname, '../packages/node/src/cli/storageSnippets.ts'), + ).href + const result = await execa( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import { registerHooks } from 'node:module' + registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('@transloadit/')) throw new Error('Workspace packages are not built yet') + return nextResolve(specifier, context) + }, + }) + await import(${JSON.stringify(source)}) + `, + ], + { reject: false }, + ) + expect(result.exitCode, result.stderr).toBe(0) +}) interface PackageManifest { + engines?: { node?: string } + files?: string[] dependencies?: Record devDependencies?: Record } +function scaffoldUploadCommand(page: string): string { + const commands: string[] = [] + function visit(node: Node): void { + if (isStringLiteral(node) && node.text.startsWith('npx transloadit storage store')) + commands.push(node.text) + forEachChild(node, visit) + } + visit(createSourceFile('page.tsx', page, ScriptTarget.Latest, true, ScriptKind.TSX)) + expect(commands).toHaveLength(1) + const command = commands[0] + if (command === undefined) throw new Error('Expected the generated upload command') + return command +} + +test.each([ + 'team photos/', + "team'photos/", + 'cash$IMG_SNIPPET_SENTINEL/', +])('the scaffold upload command preserves the literal prefix %s in a shell', async (prefix) => { + const command = scaffoldUploadCommand(storageImagePage('../../transloadit.images.json', prefix)) + // A local function captures arguments; no npx process, network request or upload runs. + const result = await execa('bash', ['-c', `npx() { printf '%s\\n' "$@"; }\n${command}`], { + reject: false, + env: { IMG_SNIPPET_SENTINEL: 'must-not-expand' }, + }) + expect(result.exitCode).toBe(0) + expect(result.stdout.split('\n')).toEqual([ + 'transloadit', + 'storage', + 'store', + './hero.jpg', + `${prefix}hero.jpg`, + ]) +}) + +test('a custom catalog remains the upload destination advertised by image init', async () => { + const page = storageImagePage('../../catalog photos.json', 'website/', 'catalog photos.json') + const command = scaffoldUploadCommand(page) + const result = await execa('bash', ['-c', `npx() { printf '%s\\n' "$@"; }\n${command}`]) + expect(result.stdout.split('\n')).toContain('--receipts=catalog photos.json') + expect(page).toContain("from '@transloadit/img/next'") +}) + +test('the generated example is already formatted for the repository Biome configuration', async () => { + const page = storageImagePage('../../transloadit.images.json', 'website/') + const result = await execa( + process.execPath, + [ + resolve(import.meta.dirname, '../node_modules/@biomejs/biome/bin/biome'), + 'format', + '--stdin-file-path=app/storage-image-example/page.tsx', + ], + { input: page, stripFinalNewline: false }, + ) + expect(result.stdout).toBe(page) +}) + +test.each([ + 'node', + 'transloadit', +])('the %s CLI declares the Node floor for JSON imports and composed cancellation', async (name) => { + const manifest = await readManifest( + resolve(import.meta.dirname, `../packages/${name}/package.json`), + ) + expect(manifest.engines?.node).toBe('>= 20.10.0') +}) + +async function imageDocumentation(): Promise { + return `${await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8')}\n${await readFile(resolve(import.meta.dirname, '../packages/img/docs/reference.md'), 'utf8')}` +} + +test('private setup prefers the least-privilege signing scope', async () => { + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + const privateRecipe = readme.slice( + readme.indexOf('## Private'), + readme.indexOf('## When it breaks'), + ) + expect(privateRecipe).toContain('smart_cdn:sign') + expect(privateRecipe).toMatch(/assemblies:write.*also accepted/) + expect(privateRecipe).toContain('Assembly') + expect(privateRecipe).toContain('// transloadit.authorize.ts') + expect(privateRecipe).toContain('getSession') + expect(privateRecipe).toContain('canRead(path) === true') + expect(privateRecipe).toContain('// app/api/storage-images/route.ts') + expect(privateRecipe).toContain("export { GET, HEAD } from '@transloadit/img/next/route'") + expect(privateRecipe).toContain('TRANSLOADIT_SMART_CDN_KEY=') + expect(privateRecipe).toContain('TRANSLOADIT_SMART_CDN_SECRET=') + expect(privateRecipe).toContain('Restart `next dev` after adding the authorizer') + expect(privateRecipe).not.toContain('placeholder="blur"') + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + expect(reference).toContain('INSUFFICIENT_AUTH_SCOPE') + expect(reference).not.toContain('A generic 403 cannot tell us') +}) + +test('image docs describe the unverified cache tag and recommend immutable names beside upload guidance', async () => { + const docs = (await imageDocumentation()).replaceAll(/\s+/g, ' ') + expect(docs).toContain( + 'cache-busting tag derived from the receipt hash; the origin does not verify it, so a cold request after an overwrite can return the replacement', + ) + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + expect(readme.slice(0, readme.indexOf('## Responsive'))).toContain('immutable filename') + expect(readme.slice(0, readme.indexOf('## Responsive'))).toContain( + 'Prefer `--hashed` for images you will replace', + ) + expect(readme.indexOf('Prefer `--hashed`')).toBeLessThan( + readme.indexOf('npx transloadit storage store'), + ) + const quickstart = readme.slice(0, readme.indexOf('## Responsive')) + expect(quickstart).toContain( + 'npx transloadit storage store ./hero.jpg website/hero.jpg --public --hashed', + ) + expect(quickstart).toContain('use the printed JSX path') + expect(quickstart).toContain( + 'Use the path printed by your upload as `src`; the hash below is only an example', + ) + expect(quickstart).toContain('src="website/hero.fce9d56a.jpg"') + expect(docs).toContain('eight hex') + expect(docs).toContain('same bytes') + expect(docs).toContain('belt-and-braces') + expect(docs).toContain('placeholder="blur"') + expect(docs).toContain('thumbhash') + expect(docs).toContain('hasAlpha: true') + expect(docs).toContain('transparent image: no blur placeholder') + expect(docs).toContain('no client-side load handler') + expect(docs).toContain('img-src data:') + expect(docs).toContain('6 KB') +}) + +test('server-upload docs connect verified receipts to an explicit private rendering factory', async () => { + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + expect(readme).toContain( + '](https://github.com/transloadit/node-sdk/blob/main/packages/node/README.md#store-an-image)', + ) + const node = await readFile(resolve(import.meta.dirname, '../packages/node/README.md'), 'utf8') + expect(node).toContain('storage store ./hero.jpg website/hero.jpg --public --hashed') + expect(node).toContain('use the printed JSX path') + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + const uploads = reference.slice( + reference.indexOf('### Images uploaded by your users'), + reference.indexOf('### Credentials and framework adapters'), + ) + expect(uploads).toContain("import { createStorageImages } from '@transloadit/img/next/server'") + expect(uploads).toContain('export const { StorageImage, storageRoute } = createStorageImages({') + expect(uploads).toContain("allowedPathPrefixes: ['uploads/']") + expect(uploads).toContain('canRead(path) === true') + expect(uploads).toContain('// app/api/upload-images/route.ts') + expect(uploads).toContain("route: '/api/upload-images'") + expect(uploads).toContain( + "export { storageRoute as GET, storageRoute as HEAD } from '../../upload-images'", + ) + expect(uploads).toContain("import { StorageImage } from '../../upload-images'") + expect(uploads).toContain(' { + const readme = await readFile(resolve(import.meta.dirname, '../packages/node/README.md'), 'utf8') + const example = readme + .slice(readme.indexOf('const transloadit = new Transloadit(')) + .split('```')[0] + expect(example).toContain("signatureAlgorithm: 'sha256'") + const constructorDocs = readme + .slice(readme.indexOf('#### constructor(options)')) + .split('\n#### ')[0] + expect(constructorDocs).toContain("signatureAlgorithm: 'sha256'") + expect(constructorDocs).toContain("default `'sha384'`") + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + const keyRecipe = reference.slice( + reference.indexOf('For private deployments, create'), + reference.indexOf('Login saves'), + ) + expect(keyRecipe).toContain("signatureAlgorithm: 'sha256'") +}) + +test('cache-key and production recovery limits are stated without implying universal Bunny behavior', async () => { + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + expect(reference).toContain('configured on `*.tlcdn.com`') + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + expect(readme).not.toMatch(/storage ls|storage receipts sync/) + expect(readme).toContain('restore the committed catalog') + expect(readme).not.toContain('[Recovery requires the Storage read API') + expect(readme).toContain( + '](./docs/reference.md#recovery-requires-the-storage-read-api-not-yet-enabled-in-production)', + ) + const recovery = reference.slice( + reference.indexOf( + '### Recovery (requires the Storage read API, not yet enabled in production)', + ), + ) + expect(recovery).toContain('storage ls') + expect(recovery).toContain('storage receipts sync') + expect(recovery).toContain('HTTP 403 cannot distinguish a disabled API from denied access') + const node = await readFile(resolve(import.meta.dirname, '../packages/node/README.md'), 'utf8') + const legacy = await readFile( + resolve(import.meta.dirname, '../packages/transloadit/README.md'), + 'utf8', + ) + expect(node).toContain('#recovery-requires-the-storage-read-api-not-yet-enabled-in-production') + expect(node.replaceAll(/\s+/g, ' ')).toContain('not yet enabled in production') + expect(legacy).toContain('#recovery-requires-the-storage-read-api-not-yet-enabled-in-production') +}) + +test('private deployment uses an application key rather than the revocable CLI login identity', async () => { + const documentation = await imageDocumentation() + expect(documentation).toContain('Credentials → New Auth Key') + expect(documentation).toContain('separate application key') + expect(documentation).toContain('TRANSLOADIT_SMART_CDN_SECRET') + expect(documentation).not.toContain("Supply the login's") +}) + +test('the reference describes the generated alt and the deliberate workspace override', async () => { + const documentation = await imageDocumentation() + expect(documentation).toContain('filename-derived alt') + expect(documentation).not.toContain('decorative empty alt') + expect(documentation).toContain('`TRANSLOADIT_WORKSPACE` overrides the catalog workspace') +}) + +test('the README is a short invitation, with operational caveats in the reference', async () => { + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + // The private recipe now lives here too; keep both paths concise without hiding setup in a link. + expect(readme.split('\n').length).toBeLessThanOrEqual(95) + expect(readme).toContain('width={960} preload') + expect(readme).toContain("import type { NextConfig } from 'next'") + expect(readme).toContain('const nextConfig: NextConfig =') + expect(readme).toContain('export default withTransloaditImages(nextConfig)') + expect(readme).not.toMatch(/Bunny|NEXT_PUBLIC_|--replace|cached bytes|`v`/) +}) + +test.each([ + 'private-images', + 'browser-images', +])('explicitly exports both GET and HEAD for the %s fixture', async (route) => { + const source = await readFile( + resolve(import.meta.dirname, `fixtures/img-next/app/api/${route}/route.ts`), + 'utf8', + ) + expect(source).toContain('as GET,') + expect(source).toContain('as HEAD') +}) + +test('documents the pinned alpha pipeline, public cache policy and combined credential contract', async () => { + const readme = await imageDocumentation() + expect(readme).toContain('builtin/storage-preview@0.0.2') + expect(readme).toContain('fallbackBackground') + expect(readme).toContain('#00000000') + expect(readme).toContain('Production Smart CDN uses Bunny') + expect(readme).toContain('whole query string') + expect(readme).not.toContain('NoCacheSigExp') + expect(readme).toContain('builtin/public-preview@0.0.1') + expect(readme).toContain('immutable') + expect(readme).toContain('TRANSLOADIT_KEY') + expect(readme).toContain('TRANSLOADIT_SMART_CDN_KEY/SECRET') + expect(readme).not.toContain('by design') +}) + async function readManifest(path: string): Promise { return JSON.parse(await readFile(path, 'utf8')) } +test('keeps the maintainer seed separate and uses the package import without default scaffolding', async () => { + const readme = await imageDocumentation() + const dogfood = await readFile(resolve(import.meta.dirname, '../docs/img-dogfood.md'), 'utf8') + expect.soft(dogfood).not.toContain('allowImportingTsExtensions') + expect.soft(/^\s*node --env-file=(\S+) seed\.ts /m.exec(dogfood)?.[1]).toBe('.env.seed.local') + const store = readme.indexOf('npx transloadit storage store ./hero.jpg website/hero.jpg --public') + const firstRender = readme.indexOf("import { StorageImage } from '@transloadit/img/next'") + expect(store).toBeGreaterThan(0) + expect.soft(firstRender).toBeGreaterThan(store) + expect.soft(readme).toContain('never reads or validates signing credentials') + expect + .soft(readme) + .not.toMatch( + /allowImportingTsExtensions|gh pr checkout|from ['"][^'"]+\.tsx?['"]|type:.*module/, + ) +}) + +test('gets to the first image before teaching the security model and keeps the private recipe complete', async () => { + const readme = await imageDocumentation() + const quickstart = readme + .slice(readme.indexOf('## Quickstart'), readme.indexOf('## Responsive')) + .trim() + expect(quickstart.split('\n').length).toBeLessThanOrEqual(40) + expect(quickstart).not.toContain('image init') + expect(quickstart).toContain('withTransloaditImages') + expect(quickstart).not.toContain('--write-env') + expect(quickstart).toContain('app/page.tsx') + expect(quickstart).toContain('transloadit-images.d.ts') + expect(quickstart).toContain('transloadit.images.json') + expect(quickstart).toContain('Start with `auth login` even without an account') + expect(quickstart).toContain('create a free workspace') + expect(readme).toContain('Older deployments may watermark Community-plan uploads') + expect(quickstart).toContain('npm run dev') + expect(quickstart).not.toContain('authorize:') + const login = readme.indexOf('npx transloadit auth login') + const store = readme.indexOf('npx transloadit storage store') + const page = readme.indexOf(' { + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + const quickstart = readme.slice(readme.indexOf('## Quickstart'), readme.indexOf('## Responsive')) + expect(quickstart).toContain('maintainer') + expect(quickstart).toContain('tarballs') + expect(quickstart).toContain('pnpm add') + expect(quickstart).toContain('yarn add') + expect(quickstart).toMatch(/choose Sign up in the\s+browser it opens/) + expect(reference).toContain('15 minutes') + expect(quickstart.indexOf('16.3.3')).toBeLessThan(quickstart.indexOf('auth login')) + expect(readme).toContain('`baseUrl` and `urlParams`') + expect(readme).toContain('`.tlcdn.com`') + const troubleshooting = `${readme.slice(readme.indexOf('## When it breaks'))}\n${reference}` + expect(troubleshooting).toContain('auth login --endpoint ') + expect(troubleshooting).toContain('persists that endpoint') + expect(troubleshooting).toContain('TRANSLOADIT_CREDENTIALS_FILE') + expect(troubleshooting).toContain('in your shell') + expect(troubleshooting).toContain('Console → Credentials') + expect(readme).not.toContain('https://transloadit.com/c/') + expect(quickstart).toContain('any JPEG you have') + expect(quickstart).toContain('If your app has `src/`, prefix the source paths') + expect(readme).toContain('Credentials') + expect(reference).toContain('## Delivery overrides') + expect(reference).toContain('baseUrl:') + expect(reference).toContain('urlParams:') + expect(reference).not.toContain('Restart development to retry') + expect(reference).toContain('density-corrected') +}) + +test('ships a focused secretless quickstart and the detailed reference it links to', async () => { + const readme = await readFile(resolve(import.meta.dirname, '../packages/img/README.md'), 'utf8') + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + const manifest = await readManifest(resolve(import.meta.dirname, '../packages/img/package.json')) + expect(manifest.files).toContain('docs') + expect(readme).toContain('](./docs/reference.md') + expect(readme.split('\n').length).toBeLessThanOrEqual(95) + expect(readme).toContain('16.3.3') + expect(reference).toContain("cacheMaxAge: '1m'") + expect(readme).toContain('then deploy') + expect(readme).toContain('width={960} preload') + expect(reference).toContain('Windows') + expect(readme).not.toContain('maxWidth=') + expect(readme).not.toMatch(/rotationIntervalMs|delivery: 'direct'|deferUntilHydrated|retryKey/) + expect(reference).toContain('Experimental') + expect(reference).toContain('This deliberately changes cache keys') + expect(reference).toContain('storage publications') + const node = await readFile(resolve(import.meta.dirname, '../packages/node/README.md'), 'utf8') + expect(node).toContain('auth login') + expect(node).toContain('storage store ./hero.jpg website/hero.jpg --public') + expect(node).toContain('auth logout') + expect(node).not.toMatch(/storage ls website\/|storage receipts sync website\//) + expect(node).toContain('Recovery options and prerequisites') +}) + test('locks every external runtime dependency of the packed image package', async () => { const repoRoot = resolve(import.meta.dirname, '..') const imageManifest = await readManifest(resolve(repoRoot, 'packages/img/package.json')) @@ -28,3 +470,62 @@ test('locks every external runtime dependency of the packed image package', asyn expect(fixtureDependencies[name], `${name} must be pinned in the fixture`).toMatch(/^\d/) } }) + +test.each([ + ['scripts/fixtures/img-next/package.json', 'scripts/fixtures/img-next/package-lock.json', 0], + ['scripts/fixtures/img-next/package.json', 'yarn.lock', 1], + ['package.json', 'scripts/fixtures/img-next/package-lock.json', 1], + ['package.json', 'yarn.lock', 0], +])('guards dependency changes in %s with %s (exit %i)', async (manifest, lockfile, exitCode) => { + const directory = await mkdtemp(resolve(tmpdir(), 'img-lockfile-test-')) + onTestFinished(() => rm(directory, { recursive: true, force: true })) + const git = (...args: string[]) => execa('git', args, { cwd: directory }) + await git('init', '--quiet') + await git('config', 'user.name', 'Fixture') + await git('config', 'user.email', 'fixture@example.invalid') + await mkdir(resolve(directory, 'scripts/fixtures/img-next'), { recursive: true }) + const initial = `${JSON.stringify({ dependencies: { react: '19.2.0' } })}\n` + await writeFile(resolve(directory, 'package.json'), initial) + await writeFile(resolve(directory, 'scripts/fixtures/img-next/package.json'), initial) + await git('add', '.') + await git( + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '--quiet', + '--no-gpg-sign', + '-m', + 'Baseline', + ) + const { stdout: base } = await git('rev-parse', 'HEAD') + await writeFile( + resolve(directory, manifest), + `${JSON.stringify({ dependencies: { react: '19.2.1' } })}\n`, + ) + await writeFile(resolve(directory, lockfile), 'Updated dependency lock\n') + await git('add', '.') + await git( + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '--quiet', + '--no-gpg-sign', + '-m', + 'Dependency change', + ) + const { stdout: head } = await git('rev-parse', 'HEAD') + + // Exercise the actual legacy inline guard, not a duplicate implementation of its lockfile policy. + const workflow = await readFile( + resolve(import.meta.dirname, '../.github/workflows/ci.yml'), + 'utf8', + ) + const guard = workflow.split("node <<'NODE'\n")[1]?.split('\n NODE')[0] + if (guard === undefined) throw new Error('CI lockfile guard was not found') + const result = await execa(process.execPath, ['--input-type=commonjs', '--eval', guard], { + cwd: directory, + env: { BASE_SHA: base, HEAD_SHA: head }, + reject: false, + }) + expect(result.exitCode, result.stderr).toBe(exitCode) +}) diff --git a/scripts/knip.test.ts b/scripts/knip.test.ts new file mode 100644 index 00000000..1007a3ad --- /dev/null +++ b/scripts/knip.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest' + +import config from '../knip.ts' +import nodePackage from '../packages/node/package.json' with { type: 'json' } +import legacyPackage from '../packages/transloadit/package.json' with { type: 'json' } + +test('best-effort image decoding is optional for SDK installation', () => { + expect(nodePackage.dependencies).not.toHaveProperty('sharp') + expect(nodePackage).toHaveProperty('optionalDependencies.sharp', '0.35.4') +}) + +test('accounts for generated compatibility dependencies before their sources exist', () => { + const legacy = config.workspaces?.['packages/transloadit'] + if (legacy === undefined || typeof legacy !== 'object') { + throw new Error('Expected the compatibility workspace configuration') + } + // Fresh CI checkouts have its manifest but no generated sources; the canonical SDK is checked. + expect(legacy.ignoreDependencies).toEqual( + expect.arrayContaining(Object.keys(legacyPackage.dependencies)), + ) +}) diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts index 8e983827..1a2e0157 100644 --- a/scripts/test-img-next-fixture.ts +++ b/scripts/test-img-next-fixture.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { cp, mkdir, mkdtemp, readdir, readFile, rename, rm } from 'node:fs/promises' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { resolve } from 'node:path' @@ -10,6 +11,13 @@ import { execa } from 'execa' import { withProcess } from './withProcess.ts' const fixtureSecret = 'fixture-secret-must-never-reach-the-browser' +const renderingEnvironment = { + TRANSLOADIT_KEY: 'fixture-auth-key', + TRANSLOADIT_SECRET: fixtureSecret, + TRANSLOADIT_SMART_CDN_KEY: undefined, + TRANSLOADIT_SMART_CDN_SECRET: undefined, + TRANSLOADIT_WORKSPACE: 'fixture', +} const benchmarkCounts: readonly number[] = [1, 20, 100] interface ImageBenchmarkResult { @@ -64,8 +72,18 @@ async function fetchWhenReady(url: string, signal?: AbortSignal): Promise Promise, + mode: 'development' | 'production' = 'production', ): Promise { + const generatedOptions = resolve( + fixtureDir, + 'node_modules/.cache/transloadit-images/options.json', + ) + // Deployment caches are disposable. Production startup must use its compiled options and + // never try to recreate this file, including on a read-only host. + if (mode === 'production') await rm(generatedOptions, { force: true }) for (let attempt = 0; attempt < 5; attempt += 1) { const port = await getFreePort() const baseUrl = `http://127.0.0.1:${port}` @@ -75,7 +93,7 @@ async function withFixtureServer( process.execPath, [ resolve(fixtureDir, 'node_modules/next/dist/bin/next'), - 'start', + mode === 'development' ? 'dev' : 'start', '-H', '127.0.0.1', '-p', @@ -83,6 +101,11 @@ async function withFixtureServer( ], { cwd: fixtureDir, + env: { + ...renderingEnvironment, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_CACHE_COMPONENTS: cacheComponents, + }, reject: false, }, ) @@ -92,12 +115,19 @@ async function withFixtureServer( const abortController = new AbortController() try { const outcome = await Promise.race([ - fetchWhenReady(`${baseUrl}/fixture/storage-image`, abortController.signal).then( - () => undefined, - ), + // The empty scaffold has no image probe before Playwright starts its local CDN. + fetchWhenReady( + `${baseUrl}/fixture/cli-empty/app/storage-image-example`, + abortController.signal, + ).then(() => undefined), server, ]) if (outcome === undefined) { + if (mode === 'production') + assert( + !existsSync(generatedOptions), + 'Production start must not regenerate image options', + ) await verify(baseUrl) return false } @@ -137,7 +167,8 @@ function getFirstPictureCandidates(html: string): string[] { const candidates: string[] = [] for (const picture of pictures) { const sourceSet = /]*\bsrcset="([^"]+)"/i.exec(picture)?.[1] - assert(sourceSet !== undefined, 'Expected every benchmark picture to contain a source set') + // Streamed HTML also contains source-free Suspense placeholders; count only resolved images. + if (sourceSet === undefined) continue const decoded = decodeHtmlAttribute(sourceSet) const separator = decoded.indexOf(' ') assert(separator > 0, 'Expected every benchmark candidate to have a width descriptor') @@ -148,6 +179,7 @@ function getFirstPictureCandidates(html: string): string[] { async function runImageBenchmark( baseUrl: string, + cdnOrigin: string, count: number, delivery: 'direct' | 'redirect', ): Promise { @@ -164,7 +196,7 @@ async function runImageBenchmark( const redirects = await Promise.all( candidates.map((candidate) => fetch(new URL(candidate, baseUrl), { - headers: { Authorization: 'Bearer fixture' }, + headers: { Cookie: 'fixture-session=fixture' }, redirect: 'manual', }), ), @@ -173,14 +205,14 @@ async function runImageBenchmark( for (const redirect of redirects) { assert(redirect.status === 307, 'Expected every authorized image route to redirect') assert( - redirect.headers.get('location')?.startsWith('https://cdn.example/') === true, + redirect.headers.get('location')?.startsWith(`${cdnOrigin}/`) === true, 'Expected every image redirect to target Smart CDN', ) assert((await redirect.text()) === '', 'An image route must not proxy response bytes') } } else { assert( - candidates.every((candidate) => candidate.startsWith('https://cdn.example/')), + candidates.every((candidate) => candidate.startsWith(`${cdnOrigin}/`)), 'Expected direct benchmark images to bypass the application route', ) } @@ -199,9 +231,17 @@ async function runImageBenchmark( async function main(): Promise { const repoRoot = resolve(import.meta.dirname, '..') + const seed = await readFile(resolve(import.meta.dirname, 'fixtures/img-next/seed.ts'), 'utf8') + const dogfoodPath = resolve(repoRoot, 'docs/img-dogfood.md') + const readme = await readFile(dogfoodPath, 'utf8') + assert( + readme.includes(`\`\`\`ts\n${seed}\`\`\``), + 'The documented seed recipe differs from the tested fixture', + ) const temporaryRoot = await mkdtemp(resolve(tmpdir(), 'transloadit-img-next-')) const fixtureDir = resolve(temporaryRoot, 'fixture') const packDir = resolve(temporaryRoot, 'pack') + const cdnOrigin = `http://localhost:${await getFreePort()}` try { // Keep the entire external execution graph reviewable and age-gated in the repository. @@ -209,32 +249,20 @@ async function main(): Promise { cp(resolve(import.meta.dirname, 'fixtures/img-next'), fixtureDir, { recursive: true }), mkdir(packDir), ]) - await execa( - 'corepack', - [ - 'yarn', - 'workspace', - '@transloadit/img', - 'pack', - '--out', - resolve(packDir, 'transloadit-img-0.0.0.tgz'), - ], - { cwd: repoRoot, stdio: 'inherit' }, - ) - await execa( - 'npm', - ['pack', resolve(repoRoot, 'packages/utils'), '--pack-destination', packDir], - { - cwd: repoRoot, - stdio: 'inherit', - }, - ) - const tarballs = (await readdir(packDir)).filter((name) => name.endsWith('.tgz')) - assert(tarballs.length === 2, `Expected two package tarballs, found ${tarballs.length}`) - const imageTarball = tarballs.find((name) => name.startsWith('transloadit-img-')) - const utilsTarball = tarballs.find((name) => name.startsWith('transloadit-utils-')) - assert(imageTarball !== undefined, 'Expected an @transloadit/img package tarball') - assert(utilsTarball !== undefined, 'Expected an @transloadit/utils package tarball') + const tarballs: string[] = [] + // Package builds share dependencies, so pack sequentially to avoid racing their dist cleanup. + for (const name of ['img', 'node', 'types', 'utils']) { + const tarball = resolve(packDir, `transloadit-${name}.tgz`) + await execa( + 'corepack', + ['yarn', 'workspace', `@transloadit/${name}`, 'pack', '--out', tarball], + { + cwd: repoRoot, + stdio: 'inherit', + }, + ) + tarballs.push(tarball) + } await execa('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: fixtureDir, stdio: 'inherit', @@ -249,105 +277,337 @@ async function main(): Promise { '--no-save', '--prefer-offline', '--package-lock=false', - resolve(packDir, utilsTarball), - resolve(packDir, imageTarball), + ...tarballs, ], { cwd: fixtureDir, stdio: 'inherit' }, ) - await execa('npm', ['run', 'build'], { cwd: fixtureDir, stdio: 'inherit' }) - - const appOutput = resolve(fixtureDir, '.next/server/app') - const outputNames = await readdir(appOutput, { recursive: true }) - assert( - outputNames.includes('storage-image.html'), - 'Expected a safe partial-prerender Storage shell', + await execa(process.execPath, ['--test', 'seed.test.ts'], { + cwd: fixtureDir, + env: { IMG_DOGFOOD_DOC: dogfoodPath }, + stdio: 'inherit', + }) + // Build the actual generated public app in isolation: no private routes that could mask a + // public-only credential dependency, no custom compiler options, and no secrets in its env. + const publicDir = resolve(temporaryRoot, 'public-only') + await cp(resolve(fixtureDir, 'app/cli-image'), publicDir, { recursive: true }) + for (const file of [ + 'package.json', + 'next.config.ts', + 'app/layout.tsx', + 'app/HydrationProbe.tsx', + ]) { + await cp(resolve(fixtureDir, file), resolve(publicDir, file)) + } + // The isolated, secretless app exercises the README's package import, not a factory. + await cp( + resolve(fixtureDir, 'app/package-public/page.tsx'), + resolve(publicDir, 'app/storage-image-example/page.tsx'), ) - assert( - outputNames.includes('storage-redirect.html'), - 'Expected redirect-delivery markup to prerender', + await execa('npx', ['--no-install', 'tsc', '--project', 'tsconfig.tooling.json'], { + cwd: fixtureDir, + stdio: 'inherit', + }) + await execa( + 'npx', + [ + '--no-install', + 'tsc', + '--ignoreConfig', + '--noEmit', + '--skipLibCheck', + '--strict', + '--jsx', + 'react-jsx', + '--module', + 'ESNext', + '--moduleResolution', + 'Bundler', + '--target', + 'ES2017', + 'untyped-consumer.tsx', + ], + { + cwd: fixtureDir, + stdio: 'inherit', + }, ) - const storageShell = await readFile(resolve(appOutput, 'storage-image.html'), 'utf8') - assert(storageShell.includes('Loading preview'), 'Storage shell fallback is absent') - assert( - !storageShell.includes('builtin%2Fstorage-preview%400.0.1'), - 'A signed Storage URL leaked into the prerendered shell', + const cliHelp = await execa( + 'npx', + ['--no-install', 'transloadit', 'storage', 'store', '--help'], + { + cwd: fixtureDir, + }, ) - await assertTreeExcludes(resolve(fixtureDir, '.next/static'), fixtureSecret) - await assertTreeExcludes(appOutput, fixtureSecret) + assert(cliHelp.stdout.includes('--receipts'), 'The packed CLI must expose storage store') + const playwright = resolve(fixtureDir, 'node_modules/@playwright/test/cli.js') + await execa( + process.execPath, + [ + playwright, + 'install', + ...(process.env.CI && process.platform === 'linux' ? ['--with-deps'] : []), + 'chromium', + 'webkit', + ], + { cwd: fixtureDir, stdio: 'inherit' }, + ) + for (const cacheComponents of ['enabled', 'omitted']) { + console.log(`Secretless public-only build: cacheComponents ${cacheComponents}`) + // Reuse the installed tree without doubling disk usage or requiring a nonstandard + // Turbopack root for symlinks. Builds are sequential; restore it before the main fixture. + await rename(resolve(fixtureDir, 'node_modules'), resolve(publicDir, 'node_modules')) + try { + await execa( + process.execPath, + [resolve(publicDir, 'node_modules/next/dist/bin/next'), 'build'], + { + cwd: publicDir, + env: { + TRANSLOADIT_WORKSPACE: undefined, + TRANSLOADIT_KEY: undefined, + TRANSLOADIT_SECRET: undefined, + TRANSLOADIT_SMART_CDN_KEY: undefined, + TRANSLOADIT_SMART_CDN_SECRET: undefined, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_CACHE_COMPONENTS: cacheComponents, + }, + stdio: 'inherit', + }, + ) + } finally { + await rename(resolve(publicDir, 'node_modules'), resolve(fixtureDir, 'node_modules')) + } + const generatedPublicHtml = await readFile( + resolve(publicDir, '.next/server/app/storage-image-example.html'), + 'utf8', + ) + assert( + generatedPublicHtml.includes('builtin%2Fpublic-preview%400.0.1'), + 'The secretless app must prerender actual public URLs', + ) + assert( + !/auth_key=|sig=|exp=/.test(generatedPublicHtml), + 'The secretless public app must not sign URLs', + ) + assert( + generatedPublicHtml.includes(`${cdnOrigin}/file/fixture/`), + 'The package import must use the plugin delivery override during the secretless build', + ) + console.log(`Production fixture: cacheComponents ${cacheComponents}`) + await execa( + process.execPath, + [resolve(fixtureDir, 'node_modules/next/dist/bin/next'), 'build'], + { + cwd: fixtureDir, + env: { + ...renderingEnvironment, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_CACHE_COMPONENTS: cacheComponents, + }, + stdio: 'inherit', + }, + ) - await withFixtureServer(fixtureDir, async (baseUrl) => { - const storageHtml = await (await fetchWhenReady(`${baseUrl}/fixture/storage-image`)).text() - const redirectResponse = await fetchWhenReady(`${baseUrl}/fixture/storage-redirect`) - const redirectLinkHeader = redirectResponse.headers.get('link') ?? '' - const redirectHtml = await redirectResponse.text() - const imagePreloads = (redirectHtml.match(/]*>/g) ?? []).filter( - (tag) => tag.includes('rel="preload"') && tag.includes('as="image"'), + const appOutput = resolve(fixtureDir, '.next/server/app') + const generatedPrivateHtml = await readFile( + resolve(appOutput, 'cli-private/app/storage-image-example.html'), + 'utf8', ) assert( - imagePreloads.length === 1, - `Expected one responsive image preload; HTML=${JSON.stringify(imagePreloads)} Link=${redirectLinkHeader}`, + generatedPrivateHtml.includes('npx transloadit storage store ./hero.jpg uploads/hero.jpg'), + 'The private scaffold must preserve public policy and ignore images outside its directory', ) - const headEnd = redirectHtml.indexOf('') + const outputNames = await readdir(appOutput, { recursive: true }) assert( - headEnd > 0 && imagePreloads.every((tag) => redirectHtml.indexOf(tag) < headEnd), - 'Responsive image preloads were not hoisted into the document head', + outputNames.includes('public-image.html'), + 'Public direct images must prerender with or without Cache Components', ) - assert(imagePreloads[0]?.includes('imageSrcSet='), 'Responsive preload srcset is absent') + const publicHtml = await readFile(resolve(appOutput, 'public-image.html'), 'utf8') assert( - storageHtml.includes('builtin%2Fstorage-preview%400.0.1'), - 'Storage Built-in is absent', + publicHtml.includes('builtin%2Fpublic-preview%400.0.1'), + 'Public HTML must already contain unsigned direct URLs', ) - assert(storageHtml.includes('r=pad'), 'Storage preview does not preserve exact dimensions') - assert(storageHtml.includes('q=45'), 'Storage preview does not apply format-specific quality') assert( - redirectHtml.includes('/fixture/api/private-images?'), - 'Authorized Storage route is absent', + !/auth_key=|sig=|exp=/.test(publicHtml), + 'Public HTML must not contain signing credentials or expiry', ) assert( - !redirectHtml.includes('builtin%2Fstorage-preview%400.0.1'), - 'Redirect markup contains a direct signed Storage URL', + publicHtml.includes('v=d41d8cd98f00b204'), + 'Public HTML must use a receipt-derived version', ) - assert(!storageHtml.includes(fixtureSecret), 'Secret leaked into Storage output') - assert(!redirectHtml.includes(fixtureSecret), 'Secret leaked into redirect output') - - const routeCandidate = getFirstPictureCandidates(redirectHtml)[0] - assert(routeCandidate !== undefined, 'Expected a redirect route candidate') - const routeUrl = new URL(routeCandidate, baseUrl) - const allowed = await fetch(routeUrl, { - headers: { Authorization: 'Bearer fixture' }, - redirect: 'manual', - }) - assert(allowed.status === 307, 'Authorized Storage route did not redirect') assert( - allowed.headers.get('location')?.startsWith('https://cdn.example/') === true, - 'Authorized Storage route did not target Smart CDN', + !publicHtml.includes('visibility:hidden'), + 'Public direct images must not emit a signing shell', ) - const allowedHead = await fetch(routeUrl, { - headers: { Authorization: 'Bearer fixture' }, - method: 'HEAD', - redirect: 'manual', - }) - assert(allowedHead.status === 307, 'Authorized Storage route did not support HEAD') - assert((await allowedHead.text()) === '', 'Authorized HEAD response contained a body') - const denied = await fetch(routeUrl, { redirect: 'manual' }) - assert(denied.status === 404, 'Unauthorized Storage route did not conceal the object') - const capability = routeUrl.searchParams.get('cap') - assert(capability !== null, 'Authorized Storage route capability is absent') - const replacement = capability.endsWith('A') ? 'B' : 'A' - routeUrl.searchParams.set('cap', `${capability.slice(0, -1)}${replacement}`) - const altered = await fetch(routeUrl, { - headers: { Authorization: 'Bearer fixture' }, - redirect: 'manual', - }) - assert(altered.status === 404, 'Storage route accepted an altered transform capability') - - const benchmarks: ImageBenchmarkResult[] = [] - for (const count of benchmarkCounts) { - benchmarks.push(await runImageBenchmark(baseUrl, count, 'direct')) - benchmarks.push(await runImageBenchmark(baseUrl, count, 'redirect')) + assert( + publicHtml.includes('max-width:960px'), + 'Public hero must be constrained without a CSS reset', + ) + assert( + outputNames.includes('storage-image.html') === (cacheComponents === 'enabled'), + 'Only Cache Components should emit a partial-prerender Storage shell', + ) + assert( + outputNames.includes('storage-redirect.html'), + 'Expected redirect-delivery markup to prerender', + ) + assert( + outputNames.includes('delivery.html'), + 'Public/private redirect markup must stay static', + ) + if (cacheComponents === 'enabled') { + const storageShell = await readFile(resolve(appOutput, 'storage-image.html'), 'utf8') + assert( + / { + const storageHtml = await (await fetchWhenReady(`${baseUrl}/fixture/storage-image`)).text() + const redirectResponse = await fetchWhenReady(`${baseUrl}/fixture/storage-redirect`) + const redirectLinkHeader = redirectResponse.headers.get('link') ?? '' + const redirectHtml = await redirectResponse.text() + const imagePreloads = (redirectHtml.match(/]*>/g) ?? []).filter( + (tag) => tag.includes('rel="preload"') && tag.includes('as="image"'), + ) + assert( + imagePreloads.length === 1, + `Expected one responsive image preload; HTML=${JSON.stringify(imagePreloads)} Link=${redirectLinkHeader}`, + ) + const headEnd = redirectHtml.indexOf('') + assert( + headEnd > 0 && imagePreloads.every((tag) => redirectHtml.indexOf(tag) < headEnd), + 'Responsive image preloads were not hoisted into the document head', + ) + assert(imagePreloads[0]?.includes('imageSrcSet='), 'Responsive preload srcset is absent') + assert( + storageHtml.includes('builtin%2Fstorage-preview%400.0.2'), + 'Storage Built-in is absent', + ) + const directCandidate = getFirstPictureCandidates(storageHtml)[0] + assert(directCandidate !== undefined, 'Expected a direct preview candidate') + const directParameters = new URL(directCandidate).searchParams + assert( + directParameters.has('w') && directParameters.has('h') && !directParameters.has('r'), + 'Storage preview must keep explicit dimensions and omit the pinned pad default', + ) + assert( + storageHtml.includes('q=45'), + 'Storage preview does not apply format-specific quality', + ) + assert( + redirectHtml.includes('/fixture/api/private-images?'), + 'Authorized Storage route is absent', + ) + assert( + !redirectHtml.includes('builtin%2Fstorage-preview%400.0.2'), + 'Redirect markup contains a direct signed Storage URL', + ) + assert(!storageHtml.includes(fixtureSecret), 'Secret leaked into Storage output') + assert(!redirectHtml.includes(fixtureSecret), 'Secret leaked into redirect output') + assert( + storageHtml.includes(' 48w') && storageHtml.includes(' 96w'), + 'Avatar candidates are absent', + ) + assert(storageHtml.includes('sizes="48px"'), 'Avatar sizes are absent') + + const routeCandidate = getFirstPictureCandidates(redirectHtml)[0] + assert(routeCandidate !== undefined, 'Expected a redirect route candidate') + const routeUrl = new URL(routeCandidate, baseUrl) + const beforeAuthorization = Date.now() + const allowed = await fetch(routeUrl, { + headers: { Cookie: 'fixture-session=fixture' }, + redirect: 'manual', + }) + assert(allowed.status === 307, 'Authorized Storage route did not redirect') + assert((await allowed.text()) === '', 'Authorized Storage route must not proxy image bytes') + const location = allowed.headers.get('location') + assert(location !== null, 'Authorized Storage route has no target') + const expiresAt = Number(new URL(location).searchParams.get('exp')) + assert( + expiresAt >= beforeAuthorization + 270_000 && expiresAt <= Date.now() + 300_000, + 'Redirect fixture did not keep its maximum five-minute grant inside the 30-second rotation bucket', + ) + assert( + allowed.headers.get('location')?.startsWith(`${cdnOrigin}/`) === true, + 'Authorized Storage route did not target Smart CDN', + ) + const allowedHead = await fetch(routeUrl, { + headers: { Cookie: 'fixture-session=fixture' }, + method: 'HEAD', + redirect: 'manual', + }) + assert(allowedHead.status === 307, 'Authorized Storage route did not support HEAD') + assert((await allowedHead.text()) === '', 'Authorized HEAD response contained a body') + const denied = await fetch(routeUrl, { redirect: 'manual' }) + assert(denied.status === 404, 'Unauthorized Storage route did not conceal the object') + const capability = routeUrl.searchParams.get('cap') + assert(capability !== null, 'Authorized Storage route capability is absent') + const replacement = capability.endsWith('A') ? 'B' : 'A' + routeUrl.searchParams.set('cap', `${capability.slice(0, -1)}${replacement}`) + const altered = await fetch(routeUrl, { + headers: { Cookie: 'fixture-session=fixture' }, + redirect: 'manual', + }) + assert(altered.status === 404, 'Storage route accepted an altered transform capability') + + const benchmarks: ImageBenchmarkResult[] = [] + for (const count of benchmarkCounts) { + benchmarks.push(await runImageBenchmark(baseUrl, cdnOrigin, count, 'direct')) + benchmarks.push(await runImageBenchmark(baseUrl, cdnOrigin, count, 'redirect')) + } + console.table(benchmarks) + await execa(process.execPath, [playwright, 'test'], { + cwd: fixtureDir, + env: { + IMG_FIXTURE_BASE_URL: baseUrl, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_CACHE_COMPONENTS: cacheComponents, + IMG_FIXTURE_OUTPUT_DIR: resolve(repoRoot, 'test-results/img-next', cacheComponents), + }, + stdio: 'inherit', + }) + }) + } + console.log('Development scaffold fixture') + await withFixtureServer( + fixtureDir, + cdnOrigin, + 'omitted', + async (baseUrl) => { + await execa(process.execPath, [playwright, 'test', '--grep', 'development scaffold'], { + cwd: fixtureDir, + env: { + IMG_FIXTURE_BASE_URL: baseUrl, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_MODE: 'development', + IMG_FIXTURE_OUTPUT_DIR: resolve(repoRoot, 'test-results/img-next/development'), + }, + stdio: 'inherit', + }) + }, + 'development', + ) } finally { await rm(temporaryRoot, { force: true, recursive: true }) } diff --git a/yarn.lock b/yarn.lock index 415b0318..f353eca8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,343 @@ __metadata: version: 8 cacheKey: 10c0 +"@aws-crypto/sha1-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha1-browser@npm:5.2.0" + dependencies: + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/51fed0bf078c10322d910af179871b7d299dde5b5897873ffbeeb036f427e5d11d23db9794439226544b73901920fd19f4d86bbc103ed73cc0cfdea47a83c6ac + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 + languageName: node + linkType: hard + +"@aws-sdk/checksums@npm:^3.1001.0": + version: 3.1001.0 + resolution: "@aws-sdk/checksums@npm:3.1001.0" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/daa0c0011f72cb26bf797e08704aa7d88b4f1f77b6d8c23336eac3ee0efd0ad401074dd07fa5174132b7edc7ab318269f2a71d8c87dbc343ddcbe23a802540d6 + languageName: node + linkType: hard + +"@aws-sdk/client-s3@npm:3.1073.0": + version: 3.1073.0 + resolution: "@aws-sdk/client-s3@npm:3.1073.0" + dependencies: + "@aws-crypto/sha1-browser": "npm:5.2.0" + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.22" + "@aws-sdk/credential-provider-node": "npm:^3.972.57" + "@aws-sdk/middleware-flexible-checksums": "npm:^3.974.32" + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.53" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.35" + "@aws-sdk/types": "npm:^3.973.13" + "@smithy/core": "npm:^3.24.6" + "@smithy/fetch-http-handler": "npm:^5.4.6" + "@smithy/node-http-handler": "npm:^4.7.6" + "@smithy/types": "npm:^4.14.3" + tslib: "npm:^2.6.2" + checksum: 10c0/92b9e7cc5a0c616864c79a60abe6807a6805ce7ea29cacb0f7f83667e7708bb3a2df28903649dbb41a6af9c9435d8b0bf49a036c6951ce8cb1b9c73cb4e8075f + languageName: node + linkType: hard + +"@aws-sdk/core@npm:^3.974.22, @aws-sdk/core@npm:^3.978.0": + version: 3.978.0 + resolution: "@aws-sdk/core@npm:3.978.0" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@aws-sdk/xml-builder": "npm:^3.972.40" + "@aws/lambda-invoke-store": "npm:^0.3.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ac333ff04ce5c868eb6e5aeadeae7a02be302d95c3b6d9e753c50bc607f8fa56a26f9d96aad9866aba9c57ef8c20dec05910abac0246a8f6cf1c8c6e0f6237a9 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:^3.972.71": + version: 3.972.71 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.71" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/0e7bfbb37e92c4d3e390c4e508d47fbb7ec25381a9dd0cb79c2e075a79ebe3ee173521c2f9b999b53f415a6a15ad7966c1e1451a997523caa3e1be185e7edee3 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:^3.972.73": + version: 3.972.73 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.73" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/b9a8a595ed9186395fad43556638e4fb34f26395c69a619c9d406a0c803a104494622ee077c6d970f0bc15c8176a0676921b30a7e38ff093b88d0dc8d5d08f63 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:^3.973.16": + version: 3.973.16 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.16" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/credential-provider-env": "npm:^3.972.71" + "@aws-sdk/credential-provider-http": "npm:^3.972.73" + "@aws-sdk/credential-provider-login": "npm:^3.972.78" + "@aws-sdk/credential-provider-process": "npm:^3.972.71" + "@aws-sdk/credential-provider-sso": "npm:^3.973.15" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.77" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/f7acfae0c005fd9b1470a6b328719d9bf8d672c57dd22e9f57e158de3c3712ecac75190662a87e7c62b4201bfaa91a81f962772e8773789515304b2f9e26537a + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:^3.972.78": + version: 3.972.78 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.78" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/c86edd5c8cfe7e112cc9498867c6af3e12a73edea9e45ee95406d7ffc06baae42bf22cf30d00be4243a2eea6f192c13cd142669cca9a3dbcd367906018779b1b + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:^3.972.57": + version: 3.972.83 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.83" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.71" + "@aws-sdk/credential-provider-http": "npm:^3.972.73" + "@aws-sdk/credential-provider-ini": "npm:^3.973.16" + "@aws-sdk/credential-provider-process": "npm:^3.972.71" + "@aws-sdk/credential-provider-sso": "npm:^3.973.15" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.77" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/50ad40d4771d3ba46c295bb504bea470fba73237e3e01f34646fd14141428d7e4d85adb73fe73f0a6e4d84b462002cad702521b7f3cea1e6a1e75711b198e22c + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:^3.972.71": + version: 3.972.71 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.71" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/a4d55be31ad01c2307ae6bb4b6b30b8be5690276e7c3bfc0ff194e8aa788ff81bde7f6c3641094a886dc6304ced38f03bbc72fce4598cfaa1a2952a267327cd7 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:^3.973.15": + version: 3.973.15 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.15" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/token-providers": "npm:3.1129.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/0dc16806de6000795b25a933bf940af9ed9a70e129c66685ce40ddb4c84f449cc8dacafcd10103e808c7b6c1aceb7a08a00c8d3646de37d369291c4eb37a7c46 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:^3.972.77": + version: 3.972.77 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.77" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/a2ed48d01eb850a0ee5e72287f02ff1402aa2eab8042b4e237fa636a58b566ee46cb1bf235ae6d61dbb6a6b4ebd44ce1d74f8aba71a250db27557746ce1d3021 + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:^3.974.32": + version: 3.974.55 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.974.55" + dependencies: + "@aws-sdk/checksums": "npm:^3.1001.0" + tslib: "npm:^2.6.2" + checksum: 10c0/08eed66a2d48bc3a448d1579bf522976ed0a710123537fdf4a2f15f4d7f2c52d391d1c81410cef813ae243aab217c76ab8e198facb98043863dcf44b15198edb + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:^3.972.53": + version: 3.972.76 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.76" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/7baaae349411e77897b3ca49670f564d085a1e8744230578127c3b010cb35ce019601fd2c4610e0ce0f6819b53fda9d3ec78d3d84b9408c9da6cbe42c9c83e23 + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:^3.997.45": + version: 3.997.45 + resolution: "@aws-sdk/nested-clients@npm:3.997.45" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/ef65f970b8e0aa55bf7a837fefbf3a042f247dc4fd7291bc0d64c5115c825e2785d8684a62e97ea7f519ed9a4251b922fbe84e6278dd77d45f1d7cf740dd525d + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:^3.996.35, @aws-sdk/signature-v4-multi-region@npm:^3.996.46": + version: 3.996.46 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.46" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/069dfb7a95663cad2e0aec1d87df8a800abad33cb49dfbe9412dad2d63ab6350328c6165166b12d50e609d8dbe5a5536aa6b1101be4d91584fbca76b2fea4d00 + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1129.0": + version: 3.1129.0 + resolution: "@aws-sdk/token-providers@npm:3.1129.0" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/bbdfaa58f2d7e7f2aa11d826d604b3464896a1149d0ab83757c1f59ec1b267a2887626a1aa8ad2859f2871e442c0a46d28215d490c8a604e54ea55216d9b7621 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.13, @aws-sdk/types@npm:^3.974.5": + version: 3.974.5 + resolution: "@aws-sdk/types@npm:3.974.5" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/803aaaa1c0675dcb564803993f3c47d96302fad461af8af80e75afc40c72228ebf669593c18e44ca09fc5acf0d1bd25966261de07844d8f11ad82aa2650252d0 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.965.10 + resolution: "@aws-sdk/util-locate-window@npm:3.965.10" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/1b1bd7eaff249120b5395afad162892185e93e58027588b38d9c90afccb5b7f30dfabf5d4f9c810a5f2bda6fdf5872cd8b1aca3beff41bcc324affb3f0d8640c + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:^3.972.40": + version: 3.972.40 + resolution: "@aws-sdk/xml-builder@npm:3.972.40" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/5b06fa0466b5ddb0e33138dc16f6a30e11e52fc5ea71f3ed72af7b24621d29c9e8103df3eed9c4f14cef50f4d345dd9e7021242a8c155a67869ee4ce79982bb4 + languageName: node + linkType: hard + +"@aws/lambda-invoke-store@npm:^0.3.0": + version: 0.3.0 + resolution: "@aws/lambda-invoke-store@npm:0.3.0" + checksum: 10c0/b4a2e6b3b5397bc606053e64270d26dc5c886336f88a98cad587b1592eec17058f8fb172f1827a9f0e591f3595cf8f01575c8c9b36cde38c06456f8a65204046 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-string-parser@npm:7.27.1" @@ -832,65 +1169,65 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:16.3.0": - version: 16.3.0 - resolution: "@next/env@npm:16.3.0" - checksum: 10c0/a1e3fccc76b4e59f0c8a3106ec2dde750b85bb2f1b628caca2067968cd5b28a5d1c33ca268f67564d7374c86dd2d47f733ef105956833b263ad71d6ca44a139f +"@next/env@npm:16.3.4": + version: 16.3.4 + resolution: "@next/env@npm:16.3.4" + checksum: 10c0/6a9adf69b7aaa1d13e255aa158ee7accce81c6c23dfc2c786b3fc6f0f4c5354b3c67e7d60be5521d322d2164ba86fc01b9dc2f3f18282f6162760de99e19fd13 languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-darwin-arm64@npm:16.3.0" +"@next/swc-darwin-arm64@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-darwin-arm64@npm:16.3.4" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-darwin-x64@npm:16.3.0" +"@next/swc-darwin-x64@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-darwin-x64@npm:16.3.4" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-linux-arm64-gnu@npm:16.3.0" +"@next/swc-linux-arm64-gnu@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-arm64-gnu@npm:16.3.4" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-linux-arm64-musl@npm:16.3.0" +"@next/swc-linux-arm64-musl@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-arm64-musl@npm:16.3.4" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-linux-x64-gnu@npm:16.3.0" +"@next/swc-linux-x64-gnu@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-x64-gnu@npm:16.3.4" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-linux-x64-musl@npm:16.3.0" +"@next/swc-linux-x64-musl@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-x64-musl@npm:16.3.4" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-win32-arm64-msvc@npm:16.3.0" +"@next/swc-win32-arm64-msvc@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-win32-arm64-msvc@npm:16.3.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.3.0": - version: 16.3.0 - resolution: "@next/swc-win32-x64-msvc@npm:16.3.0" +"@next/swc-win32-x64-msvc@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-win32-x64-msvc@npm:16.3.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -1397,6 +1734,98 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.24.6, @smithy/core@npm:^3.33.2, @smithy/core@npm:^3.33.3": + version: 3.33.3 + resolution: "@smithy/core@npm:3.33.3" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/57c5c6c1834d84eddfff905932350973afa20e1006024b82d6599af4c8d1e75b243e06b93c998acc95946399f1342ddc298c6941e0030384e6541ceec4007376 + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.4.16": + version: 4.5.2 + resolution: "@smithy/credential-provider-imds@npm:4.5.2" + dependencies: + "@smithy/core": "npm:^3.33.2" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/d5481a7797a1f485849d92f6c188cfb9411736ae2469e27394b863e107dd1024266baa8e3a62cfc3f75b3abfd84b3c389955a0e96b64c4ad90462c70bbe66ab0 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.4.6, @smithy/fetch-http-handler@npm:^5.7.2": + version: 5.8.0 + resolution: "@smithy/fetch-http-handler@npm:5.8.0" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10c0/8035961bad01fd80de32caf2bb9b035cf6e102c8586790017660fa4440ba0e9ee126942b58f2ca456e94972e90c25d863378939c252b903d2741c7b05f202279 + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.11.3, @smithy/node-http-handler@npm:^4.7.6": + version: 4.12.1 + resolution: "@smithy/node-http-handler@npm:4.12.1" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10c0/a657259f8ebbff531cad854e9e12ff3f98b84c095965f4f9a6e8a3e1af4f5bbbb1bc8a7228df220663872c2eef74b5403d148dbe6be1edee06a3baa93cbeb5dc + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.6.12": + version: 5.7.3 + resolution: "@smithy/signature-v4@npm:5.7.3" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/6976142320c7c112ee817f5329d0adc45c1ec101a095a70e4afcb7e3fa9f18cd4f544dcbb481e1932fb877197ec9e1037054bacda422cccfd8de7eed55905319 + languageName: node + linkType: hard + +"@smithy/types@npm:^4.14.3, @smithy/types@npm:^4.17.2, @smithy/types@npm:^4.18.0": + version: 4.18.0 + resolution: "@smithy/types@npm:4.18.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/f948eaf2c6004ce919a5a203615da4d2d4923465df764c2f6ab982fcacde10c81e1fd23c40f983387459ffdad056f8e827ebecaa776a4331ed4f6431ad8bdd34 + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -1404,12 +1833,12 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:0.5.15": - version: 0.5.15 - resolution: "@swc/helpers@npm:0.5.15" +"@swc/helpers@npm:0.5.23": + version: 0.5.23 + resolution: "@swc/helpers@npm:0.5.23" dependencies: tslib: "npm:^2.8.0" - checksum: 10c0/33002f74f6f885f04c132960835fdfc474186983ea567606db62e86acd0680ca82f34647e8e610f4e1e422d1c16fce729dde22cd3b797ab1fd9061a825dabca4 + checksum: 10c0/02da7b4df465693933ecd4851cc193ec729c309939c8a84eccae5ec0010aafc3894e713b8ef8d13a6ba401759f0e900c88e2dcfef5872c27bb91e70f73275cce languageName: node linkType: hard @@ -1429,12 +1858,13 @@ __metadata: "@types/react": "npm:^19.2.14" "@types/react-dom": "npm:^19.2.3" happy-dom: "npm:^20.9.0" - next: "npm:16.3.0" + next: "npm:16.3.4" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" server-only: "npm:^0.0.1" + thumbhash: "npm:0.1.1" peerDependencies: - next: ">=16.0.0 <17.0.0" + next: ">=16.3.3 <17.0.0" react: ">=19.0.0 <20.0.0" react-dom: ">=19.0.0 <20.0.0" peerDependenciesMeta: @@ -1468,6 +1898,7 @@ __metadata: version: 0.0.0-use.local resolution: "@transloadit/node@workspace:packages/node" dependencies: + "@aws-sdk/client-s3": "npm:3.1073.0" "@transloadit/sev-logger": "npm:^0.1.9" "@transloadit/utils": "npm:^4.8.1" "@types/debug": "npm:^4.1.13" @@ -1494,10 +1925,15 @@ __metadata: p-retry: "npm:^8.0.0" recursive-readdir: "npm:^2.2.3" rimraf: "npm:^6.1.3" + sharp: "npm:0.35.4" + thumbhash: "npm:0.1.1" tus-js-client: "npm:^4.3.1" typanion: "npm:^3.14.0" type-fest: "npm:^5.6.0" zod: "npm:3.25.76" + dependenciesMeta: + sharp: + optional: true bin: transloadit: ./dist/cli.js languageName: unknown @@ -2163,6 +2599,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.14.1 + resolution: "bowser@npm:2.14.1" + checksum: 10c0/bb69b55ba7f0456e3dc07d0cfd9467f985581f640ba8fd426b08754a6737ee0d6cf3b50607941e5255f04c83075b952ece0599f978dd4d20f1e95461104c5ffd + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.14 resolution: "brace-expansion@npm:1.1.14" @@ -4482,24 +4925,24 @@ __metadata: languageName: node linkType: hard -"next@npm:16.3.0": - version: 16.3.0 - resolution: "next@npm:16.3.0" +"next@npm:16.3.4": + version: 16.3.4 + resolution: "next@npm:16.3.4" dependencies: - "@next/env": "npm:16.3.0" - "@next/swc-darwin-arm64": "npm:16.3.0" - "@next/swc-darwin-x64": "npm:16.3.0" - "@next/swc-linux-arm64-gnu": "npm:16.3.0" - "@next/swc-linux-arm64-musl": "npm:16.3.0" - "@next/swc-linux-x64-gnu": "npm:16.3.0" - "@next/swc-linux-x64-musl": "npm:16.3.0" - "@next/swc-win32-arm64-msvc": "npm:16.3.0" - "@next/swc-win32-x64-msvc": "npm:16.3.0" - "@swc/helpers": "npm:0.5.15" + "@next/env": "npm:16.3.4" + "@next/swc-darwin-arm64": "npm:16.3.4" + "@next/swc-darwin-x64": "npm:16.3.4" + "@next/swc-linux-arm64-gnu": "npm:16.3.4" + "@next/swc-linux-arm64-musl": "npm:16.3.4" + "@next/swc-linux-x64-gnu": "npm:16.3.4" + "@next/swc-linux-x64-musl": "npm:16.3.4" + "@next/swc-win32-arm64-msvc": "npm:16.3.4" + "@next/swc-win32-x64-msvc": "npm:16.3.4" + "@swc/helpers": "npm:0.5.23" baseline-browser-mapping: "npm:^2.9.19" caniuse-lite: "npm:^1.0.30001579" postcss: "npm:8.5.23" - sharp: "npm:^0.35.3" + sharp: "npm:^0.35.4" styled-jsx: "npm:5.1.6" peerDependencies: "@opentelemetry/api": ^1.1.0 @@ -4538,7 +4981,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/f1bf9f608a4604348ea204cd0abd174bbe140813509168a8a4456ad9ce9f2515aff8564821dbca8234f919ba12cb295fad82c0649f73e437417cce146bb1eb39 + checksum: 10c0/e4f2f079159201c205ba373e34f9517d581947ed0cf67860d24a81073d8e388cb041b5fbc0d4a527e1523f234d14f1d3d17dee13f930ec29f2c23abb406d7508 languageName: node linkType: hard @@ -5688,7 +6131,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.35.3": +"sharp@npm:0.35.4, sharp@npm:^0.35.4": version: 0.35.4 resolution: "sharp@npm:0.35.4" dependencies: @@ -6153,6 +6596,13 @@ __metadata: languageName: node linkType: hard +"thumbhash@npm:0.1.1": + version: 0.1.1 + resolution: "thumbhash@npm:0.1.1" + checksum: 10c0/9a23fe65a32b82400a966968a426f7e8a1bee352eedfc24b5342ebfe63f36e2cf8bba5b93adddf220bba625da2ad819ae5fbb6e9d5caa6f215e9ae274249bf87 + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -6221,6 +6671,7 @@ __metadata: version: 0.0.0-use.local resolution: "transloadit@workspace:packages/transloadit" dependencies: + "@aws-sdk/client-s3": "npm:3.1073.0" "@transloadit/sev-logger": "npm:^0.1.9" "@transloadit/utils": "npm:^4.8.1" "@types/debug": "npm:^4.1.13" @@ -6230,6 +6681,7 @@ __metadata: clipanion: "npm:^4.0.0-rc.4" debug: "npm:^4.4.3" dotenv: "npm:^17.4.2" + execa: "npm:9.6.1" form-data: "npm:^4.0.5" got: "npm:14.6.6" into-stream: "npm:^9.1.0" @@ -6240,16 +6692,21 @@ __metadata: p-map: "npm:^7.0.4" p-queue: "npm:^9.3.0" recursive-readdir: "npm:^2.2.3" + sharp: "npm:0.35.4" + thumbhash: "npm:0.1.1" tus-js-client: "npm:^4.3.1" typanion: "npm:^3.14.0" type-fest: "npm:^5.6.0" zod: "npm:3.25.76" + dependenciesMeta: + sharp: + optional: true bin: transloadit: ./dist/cli.js languageName: unknown linkType: soft -"tslib@npm:^2.4.0, tslib@npm:^2.8.0": +"tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62