diff --git a/.changeset/cli-list-fields.md b/.changeset/cli-list-fields.md new file mode 100644 index 00000000..66e1f5ac --- /dev/null +++ b/.changeset/cli-list-fields.md @@ -0,0 +1,7 @@ +--- +'@transloadit/node': patch +'transloadit': patch +--- + +Honor selected fields in JSON output from Template and Assembly list commands, and lead CLI +onboarding with browser login while retaining explicit credentials for automation. 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/public-preview-limits.md b/.changeset/public-preview-limits.md new file mode 100644 index 00000000..89683c62 --- /dev/null +++ b/.changeset/public-preview-limits.md @@ -0,0 +1,6 @@ +--- +'@transloadit/utils': patch +--- + +Keep public-preview candidates within their dimension and quality limits, including width-only +requests and tall sources. Share the exact Built-in limits with image delivery adapters. 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..2e1568f7 --- /dev/null +++ b/.changeset/storage-image-receipts.md @@ -0,0 +1,95 @@ +--- +"@transloadit/node": minor +"transloadit": minor +"@transloadit/mcp-server": patch +--- + +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 `getStoredAssemblyResults()` for verified completed batches of any retained media, with +Assembly/step/result/input provenance. Add native `moveStoredAsset()` / `deleteStoredAsset()`; +moves return the canonical transaction snapshot and preserve existing references. +`getStoredAssetUrl()` signs exact original bytes through `builtin/storage-serve@0.0.3`, with an +optional safe Unicode attachment filename and a bounded, cache-rotated lifetime. Requires a +backend with that Built-in and canonical native mutation responses. + +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 Image storage 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. Document native catalog recovery in the image reference. +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, bounded native catalog pages without per-file HEAD requests, an Assembly or an original +download. Recover canonical Workspace, asset ID, retained version ID, current path, dimensions, +MIME and available checksums. 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. +Record API provenance for every upload, not only hashed uploads, and on the catalog even when +publication happens before the first upload. Reject API-environment mismatches +even when Workspace slugs are identical; a custom delivery host is not an API identity. Recover +unbound legacy receipts into a separate catalog before reviewing and replacing the old file. +Recovery records the verified API origin so hashed uploads can be reused without uploading. +Existing rendering catalogs require this recovery before adopting the version-addressed Viewer. +Redeploy the application to regenerate private capability-v2 URLs; old capability URLs are not +accepted by the new handler. New Built-ins select actual retained versions, not arbitrary cache tags. + +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 +local placeholders on sync only when the canonical asset ID and version ID still match. + +Rename the unpublished image package to `@transloadit/viewer` and expose `Image` with mutually +exclusive `storage` and `template` selectors and a separate `workspace` prop. Custom HTTP/S3 +Templates do not require a Storage catalog or inherit its publication policy. Keep credentials +server-only and authorize the full workspace, Template and path identity for private redirects. + +Consolidate the unpublished Next factories into `createImages`; 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/README.md b/README.md index 15fc8623..70f9db68 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server. ## Packages -- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See +- `@transloadit/viewer` — Private image-only candidate for Storage and Smart CDN Templates. See `packages/img/README.md`. - `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`. - `transloadit` — Stable unscoped package (built from `@transloadit/node`). 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..f263be79 --- /dev/null +++ b/docs/img-dogfood.md @@ -0,0 +1,483 @@ +# `@transloadit/viewer` 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. + +## September 17 consumer release gates + +Publish the minor Node/legacy release only after API2 #9057 and Content #5973 are deployed. +Device login, public delivery and safe CLI-key revocation need that coordinated rollout. +Apply the CLI approval-key-identity migration before deploying API2; logout now signs explicit +`action: 'revoke_auth_key'` intent. Native move responses and `builtin/storage-serve@0.0.3` must +be available before publishing the new consumer helpers. Viewer remains private dogfood. +At publication, replace preview-branch documentation links with their merged main locations. +Production Bunny checks, independent backup/restore and app-owned access checks remain gates; +passing the local private-media fixture is not a production deployment claim. + +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 `Image` imported from +`@transloadit/viewer/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. +Storage uses relative catalog paths; custom HTTP/S3 Templates accept relative inputs with trusted +intrinsic dimensions. Their fixed origins and import credentials belong in the Template, not JSX. +See the package reference's custom Template recipe; the historical canaries below cover Storage. + +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.3`. Public delivery additionally requires +`builtin/public-preview@0.0.2` and server-declared public prefixes. These versions take an asset ID +and real version ID; recover older catalogs with `storage receipts sync` against their original API +environment and rebuild the app to regenerate private capability-v2 URLs. 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/viewer pack --out "$img_pack_dir/transloadit-viewer.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-viewer.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 `workspace`, `asset_id`, `version_id`, final `path`, `size`, `mime`, +`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` survives rename; `version_id` selects the exact retained bytes even after overwrite. +Pass the whole receipt as `src`: its IDs drive delivery, its path bounds application authorization, +and its dimensions drive layout. Public and signed CDN URLs use the asset ID as input and version +ID as `v`; they are identities, not credentials. Public historical access follows the asset's current +location and publication policy. Private capabilities seal both IDs until the route authorizes them. +The rendering package does not import the Assembly client or look up metadata at render time. + +### 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. +`createImages` accepts these same `baseUrl` and `urlParams` fields directly, +alongside `allowedPathPrefixes` and `authorize`. + +The factory exports `Image`. Use one flat `createImages({ 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 + +Historical evidence below describes the earlier S3 List + HEAD implementation, not the current +native read contract. Current `storage ls` and `storage receipts sync` use signed `/dam/assets` +pages with real asset/version IDs, no per-object HEAD and no S3 enablement requirement. Re-run +against an API2 deployment containing that contract before claiming current live verification. + +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/viewer 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/viewer 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/viewer` 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/viewer` 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/docs/prompts/2026-09-16-image-sources.md b/docs/prompts/2026-09-16-image-sources.md new file mode 100644 index 00000000..e6ef7bd5 --- /dev/null +++ b/docs/prompts/2026-09-16-image-sources.md @@ -0,0 +1,296 @@ +# Image source model experiment + +## Accepted implementation — September 16 + +Kevin chose separate `workspace` and `template` props, with `Image storage` selecting the +integrated Storage path. This supersedes the named-source recommendation below, which remains +as the experiment record. No source registry or qualified workspace/template string is needed. + +Active checkouts: node-sdk `img-onboard` (#500), Content `cli-auth-and-combined-keys` (#5973). +Baseline SDK `5393b4d`, Content `48cf9235f2`. Existing API2 devdock is reused without restart. + +- [x] Red-first workspace, selector and cross-source policy tests. +- [x] Implement the real package API, remove the fixture-only source layer, and rename the + unpublished package to `@transloadit/viewer` (images only). +- [x] Update CLI output, generated declarations, docs and packed-consumer fixtures. +- [x] Verify actual HTTP/S3 transforms and native browser delivery, including denied access. +- [x] Update Content's dogfood consumer and verify it with a packed candidate without visual changes. +- [x] Run security/council reviews and required checks; fix valid findings red-first. +- [ ] Commit/push the SDK, replace Content's provisional vendor path with that exact commit, + and monitor both PR heads. No merge or publication. + +Guardrails: explicit workspace beats an environment default, but metadata/credential bindings +must not cross workspaces. Custom templates never inherit Storage publication policy. Keep +secrets server-only and reuse the existing image renderer. No merge, npm publication, production +configuration change or new media renderer is authorized by this slice. + +Implementation evidence (local): full SDK `verify:full` passes, including 339 image tests and +636 Node tests. All 126 Chromium/WebKit packed browser checks pass. Native source selection uses +the real package exports; the temporary alias wrapper is deleted. A separate live API2 probe +decodes 100×100 WebP with alpha from both HTTP and S3 imports; its two temporary local Templates +were deleted afterward. Legacy fixtures still ignore requested formats; the new development +HEAD diagnostic names a mismatched MIME without exposing the signed query. + +Content's actual page renders/decode-checks at 1440px and 390px with the candidate package and +local API2 bytes: 960×540 and 342×192.375 CSS boxes, eager/high-priority, no console errors. The +browser alone rewrites that toy asset to devdock; no catalog/env/production delivery policy was +changed. 121 focused Content tests and 22 CLI approval tests pass. The full check found one missed +escaped package name in the CLI-copy test (12,016 others pass); it is fixed. A broad rerun passed +12,015 tests but timed out in two unchanged i18n/alphalib tests under concurrent local load. Both +pass in isolated reruns without source or timeout changes (including all 48 alphalib-sync tests). + +Council/security findings were verified red-first (eight failing behavior checks, plus the type +check). Custom-template inputs now reject URL escapes/query/fragment delimiters before signing +and route authorization, while Storage retains literal-key semantics. Both private doc recipes +scope their authorizer to the Storage template; CLI scaffolding names the complete identity. +Conventional template props omit configuration-dependent Suspense customization; an explicit +direct factory remains available. A late authorizer file now gets restart guidance for templates +too. The remaining root README import was updated. + +The project-workspace conflict check is intentionally retained, not changed to silently fall back +to catalog identity: a configured default must not rebind metadata or signing identity. Its +misleading JSDoc was corrected and the fail-closed behavior tested. Arbitrary template choices +remain request-scoped, including diagnostics, rather than accumulating in a global registry. +An additional catalog-free test now proves cross-workspace capability rejection through the +cryptographic binding, not merely the default-workspace precheck. + +The second security review passes with no actionable security regression. The second council +found only misleading missing-catalog setup advice: a new failing test reproduced it, and the +error now names the catalog, upload command, plugin and first-catalog development restart. +The pending release note now uses the renamed API too. Final root `yarn check` passes, including +that new regression (340 image / 636 Node tests). The explicit factory's existing custom-template plus public +prefix combination still requires an explicit compatible public Template; the conventional +custom path does not accept or inherit public prefixes. + +Evidence: `/tmp/viewer-security-20260916/result.json`, `/tmp/viewer-security-review.md`, +`/tmp/viewer-council.log`, `/tmp/viewer-council-red.log`, `/tmp/viewer-council-green.log`, +`/tmp/viewer-security-followup.md`, `/tmp/viewer-council-followup.log`, +`/tmp/viewer-catalog-red.log`, `/tmp/viewer-final-package-check.log`, +`/tmp/viewer-content-browser-20260916/result.json`. Exact-head repacking, commit/push and hosted +checks are still pending; these local results do not clear production +API/CDN availability, registry publication or the existing Content Vercel OOM (#5999) gate. + +## Historical experiment (superseded by the accepted implementation above) + +September 16, 2026. Recommendation, not a released API. The prototype lives only in the packed +Next.js fixture in [node-sdk #500](https://github.com/transloadit/node-sdk/pull/500). Production +exports, package names, Storage behavior and dependencies are unchanged. + +## Outcome + +Keep one image renderer, with **Transloadit Storage as the integrated source and named sources for +customers who already have HTTP assets or S3 objects**. Do not require those customers to move their +originals into Storage. A source binds delivery policy and metadata as well as a processing template. + +The earlier `StorageImage` name describes the current adapter, not a Smart CDN limitation. Both +importers work behind the existing renderer when their templates implement its rendition contract. + +Proposed public shape, using the separately agreed Viewer package name: + +```tsx +import { Image } from '@transloadit/viewer/next' + +A canal house +A canal house +An oak chair +``` + +`storage` is shorthand for the integrated Storage source. `source` selects a trusted, configured +source, not an arbitrary URL or Robot. They are mutually exclusive. Bare `` should +only be accepted when a default source is established; this experiment deliberately has no default. +Do not introduce both `storage` and `source="storage"` spellings merely for symmetry. + +The import above is illustrative. The runnable prototype imports `Image` from its fixture-local +module; it does not add `@transloadit/viewer` or an `Image` export to the current package. + +## What was compared + +| Shape | Experiment | Assessment | +| --- | --- | --- | +| `Image storage` / `Image source="products"` | Typed and rendered in both browser engines | Preferred: JSX describes the asset collection, while configuration owns its implementation. | +| `Image storage` / `Image template="fixture-http"` | Typed and rendered alongside the first shape | Works, but puts processing-template identity into JSX without describing metadata, workspace or access policy. | +| One factory-bound component per source | Existing factories underpin both prototypes | Useful low-level escape hatch. Applications can use a local `ProductImage` without a mandatory registry. | + +For the same HTTP source, the template-selector and source-selector variants emitted the same +delivery URL. Neither syntax has an inherent bandwidth advantage. Source names are preferable for +maintenance and policy clarity, not speed: switching templates or buckets need not change JSX. + +The prototype intentionally exposes only a constrained-layout subset (`alt`, `width`, `preload`). +It is not a finished general-purpose component API or evidence that every existing prop composes +with multiple sources. + +## The important live finding: a template ID is not a compatibility contract + +On the already-running owned API2 devdock, all four requests asked for `f=webp`: + +| Source/template | HTTP status | Actual response | +| --- | --- | --- | +| Existing S3 `previews` | 200 | JPEG: the template did not map the requested format. | +| Existing HTTP `url-preview` | 200 | PNG: the template hardcoded PNG. | +| Temporary compatible S3 template | 200 | WebP. | +| Temporary compatible HTTP template | 200 | WebP. | + +The follow-up probe checked the body signatures as well as `Content-Type`. Merely getting a 200 +image response is insufficient: declaring WebP in `` while serving PNG is a broken +integration contract. Incorrect geometry, cropping or alpha behavior can be similarly silent. + +The temporary templates used `/s3/import` or an origin-pinned `/http/import`, then `/image/resize` +and `/file/serve`. They mapped the existing image component's fields: + +- `input`: validated relative source path, resolved against the configured bucket/origin. +- `w`, `h`: requested dimensions. +- `f`, `q`: output format and quality. +- `r`, `bg`: resize strategy and background/alpha behavior. + +Both required signatures and disabled step overrides. HTTP pinned `https://transloadit.com/` and +capped input size at 16 MiB. S3 reused the devdock's existing fixture credential reference. Temporary +templates were deleted after each probe; existing templates and credentials were not modified. + +These probes prove HTTP/S3 import execution and requested WebP output. They do **not** certify the +full crop, orientation, alpha, animation, authorization or cache contract. Those remain adapter +acceptance tests before productizing it. The live HTTP and S3 originals were different images, so +their sizes and request durations are not a comparative performance benchmark. + +## Customer model + +Put these concerns in a small server-side source binding, reusing the current factory and URL +builder rather than creating a new plugin framework: + +- Workspace and compatible transformation template, including version and field mapping. +- Relative-path restrictions and the trusted HTTP origin or S3 bucket/prefix. Credentials stay in + Transloadit template credentials; never put bucket secrets in JSX or browser code. +- Metadata: an optional static catalog, or trusted image metadata from an existing CMS/database. +- Publication/access policy and signing/delivery configuration. + +A template override belongs here. Do not infer behavior from template names or permit arbitrary +caller-selected templates. Provide a canonical compatible recipe and a setup-time delivery probe +that checks format and geometry, with actionable errors. Avoid metadata/network discovery during +each image render. + +Storage keeps its strongest advantages: upload receipts, generated path types, conventional setup, +public-prefix enforcement and first-party defaults. External sources should not require a fake +Storage receipt or an upload. The existing image-source structure already needs only `path`, +`width` and `height`; optional checksum/blur metadata must retain truthful provenance. Passing +trusted CMS metadata is a proposed supported path, not a new live CMS integration tested here. + +Static catalogs can give each source its own typed paths. Do not force a large or user-generated +asset collection into one repository-wide manifest and literal union. Runtime path and access +checks remain necessary even when TypeScript accepts a source/path pair. The experiment's five +negative type cases protect the proposed selector boundary, not an authorization boundary. + +Keep relative `src` paths for configured external origins, so moving a bucket or CDN host does not +require editing content. Full remote URLs could be a deliberate later capability with origin and +redirect/SSRF controls; they must not quietly enable an unrestricted transformation/billing proxy. + +### Where the friction actually was + +1. **Silent template incompatibility:** the legacy templates returned successful images in the + wrong requested format. This deserves a setup check, not a troubleshooting footnote. +2. **Storage-shaped setup:** the conventional Next plugin currently requires one Storage catalog. + The experiment needed explicit factories for the external sources. A rename alone does not + make the external-source onboarding work. +3. **Global workspace precedence:** code inspection found that `TRANSLOADIT_WORKSPACE` overrides + an explicit factory workspace. All three experimental sources used one workspace; mixed + workspaces are not proven and need an explicit per-source precedence contract and tests. +4. **Delivery mode affects rendering:** signed direct sources made the page dynamic. Keep that + choice explicit without making every customer understand capabilities on the first image. +5. **Storage-specific diagnostics:** existing factory errors and development notes still say + Storage. An HTTP customer should receive source/template-specific guidance, not an instruction + to upload the asset to Storage. + +The syntax wrappers were small. Template compatibility and these configuration boundaries are +where the product work is, not in replacing the component's exported name. + +## Delivery and caching are separate choices + +The native browser proof delivered Storage through an unsigned public Built-in and HTTP/S3 through +signed direct URLs. All image bodies went to the delivery origin, never through Next's optimizer +or a Next image-byte proxy. The adapter adds no Client Component or dependency. + +Direct private signing uses request-time work: Next marked the experiment route dynamic, and its +development diagnostic explained this. A named source does not make a signed page statically +cacheable. Preserve the existing redirect option for private authorization; it adds a per-image +authorization/signing request, not an image-byte proxy. Public delivery can use stable direct URLs +only where the backend actually enforces the intended publication and transformation policy. + +Storage public prefixes do not automatically publish or constrain S3/HTTP imports. Start custom +sources signed. A public custom source needs an independently verified policy: origin/bucket/path +restrictions, bounded transformations and costs, plus the relevant import redirect/SSRF controls. +Do not reintroduce the removed arbitrary-origin Built-in as a convenience default. + +Mutable external objects need an explicit version/cache invalidation strategy. Do not label an +unverified `v` tag or arbitrary S3 ETag as a byte checksum. Production Bunny cache behavior was +not measured in this experiment. + +## Evidence and reproduction + +Source baselines: node-sdk `80b20c8433f00573f9fd9d83330011fe898fb8cd`, API2 +`5c9d4a538f9e891b32dd223a1cb35fde6e5fb279`, Content `48cf9235f2`. Host Node was 26.7.0; +the packed app used Next.js 16.3.4. No claim of a fresh Node 24 or production deployment test. + +From node-sdk, after its normal locked dependency installation: + +```bash +corepack yarn workspace @transloadit/img check +corepack yarn verify +corepack yarn test:img:fixture +``` + +- Image baseline: 320 tests, build and type checks passed. +- Selector type proof: valid Storage/HTTP/S3/template cases; compile-time rejection of wrong-source + paths, simultaneous selectors, missing source, unknown template and full remote URL input. +- Red-first browser run: the new test failed in Chromium and WebKit because the local delivery + fake did not recognize the configured source templates; the other 56 tests passed. +- Green packed browser run: 58 tests with `cacheComponents` enabled, 58 with it omitted, and 10 + development tests. Each production run included the new source test in both engines. The new + route checked native decode, 320px geometry, correct source/template paths, expected signatures, + no selector props leaking to DOM attributes, and direct delivery-origin requests. +- Independent browser-tool inspection by the same implementer: desktop and 390px mobile native + decode, no horizontal overflow, no browser errors. This was **not** an independent blind-reader + customer study or a timed onboarding comparison. +- `yarn verify` passed, including 320 image and 636 Node tests (one Node test skipped). An existing + unused-variable warning and existing dependency/runtime warnings remain; they were not hidden. + +The fixture serves identical generated image bytes through three simulated sources. The live API2 +probes separately exercised actual HTTP and S3 importers. We did not repeat the earlier live +Transloadit Storage upload canary, nor run a single browser page against all three live backends. + +The prototype lives in `scripts/fixtures/img-next/app/source-model/`, with assertions in +`scripts/fixtures/img-next/browser.spec.ts`. A fixture-only TypeScript import mismatch was fixed +by placing its type proof alongside the consumer app, using the existing stock-Next import rules; +no consumer TypeScript options or package exports were changed. + +Local evidence (not committed artifacts or durable CI links): + +- `/tmp/img-source-model-red.log`, `/tmp/img-source-fixture-red.log` +- `/tmp/img-source-model-types2.log`, `/tmp/img-source-verify2.log` +- `/tmp/img-source-fixture-green2.log`, `/tmp/img-source-fixture-final.log` +- `/tmp/img-source-live-format.log`, `/tmp/img-source-live-cleanup.log` +- `test-results/source-model/desktop.png`, `test-results/source-model/mobile.png` + +The browser session and its two owned manual servers were stopped. The pre-existing API2 devdock +was reused, not bootstrapped, restarted or stopped. No `.env` files or production settings changed. + +## Next bounded slice, if this direction is accepted + +1. **node-sdk:** extract neutral image/source names from the current Storage adapter while retaining + one renderer. Add typed source bindings and keep the Storage convention easy. Prove an external + source with trusted CMS metadata, without a static catalog. Cover source-specific diagnostics + and workspace/credential isolation. Rename the unpublished package and internal consumers + together; do not publish this fixture API as-is. +2. **Content:** bind the existing website image origin/template as a real HTTP source and dogfood + it next to Storage. Its current template uses `fit` and a subset of fields, so validate/adapt + its rendition contract before claiming full component compatibility. Preserve visual geometry, + alpha, responsive downloads, page-load budgets and its signing policy. +3. **API2:** add importer-specific end-to-end acceptance coverage for the rendition contract. A + signed customer-template path already works; no new arbitrary-origin Built-in is necessary. + Any anonymous external-source profile is a separate bounded backend/security decision. +4. **Release/Uppy/docs:** update SDK exports, packaging, fixtures and Content's vendored dependency + together before npm publication. Uppy needs no change for this image-source experiment. Expand + shared preview consumers and public recipes only against the proven released contract. + +No Terraform/CDN change is required by the selector experiment. Identify and review any actual +production delivery-policy change separately. Update the cross-repo +[Viewer plan](https://github.com/transloadit/content/blob/cli-auth-and-combined-keys/repodocs/transloadit-viewer.md) +when accepting the broader source direction; this report refines its Storage-only assumption, +without turning the current task into implementation of every planned renderer. diff --git a/docs/prompts/2026-09-17-viewer-dx-fixes.md b/docs/prompts/2026-09-17-viewer-dx-fixes.md new file mode 100644 index 00000000..26460574 --- /dev/null +++ b/docs/prompts/2026-09-17-viewer-dx-fixes.md @@ -0,0 +1,46 @@ +# Viewer DX review follow-up + +Why: the independent September 17 reader test proved the public Storage path, but found an +inconsistent Template image layout and misleading CLI field selection. The general CLI README +also needs to lead with browser login. Keep the agreed Image API; do not introduce a source registry. + +PR: https://github.com/transloadit/node-sdk/pull/500 + +## Checklist + +- [x] Read PR description, comments and reviews: no unresolved review threads. +- [x] Fetch and merge latest main (already included on September 17). +- [x] Red-first: constrained responsive defaults for string, receipt and catalog sources; + explicit `layout="none"`, sizes and style overrides remain available. +- [x] Red-first: `templates list --fields id,name --json` returns only selected fields. + Pattern sweep found the same issue in `assemblies list`; fixed with its own failing test. +- [x] Lead the general CLI Quick Start with `auth login`; document manual/CI alternatives. +- [x] Root `yarn check`: 344 image and 641 Node tests pass (one existing Node skip); + 147 targeted layout/list tests pass. The legacy wrapper README is regenerated. +- [x] Initial packed Chromium/WebKit fixture: 126 cases pass; exact-head CI at `6469d83` green. +- [x] Council findings reproduced red-first: enforce public Built-in limits in low-level unsigned + candidates, and retain compatible signed delivery for old private capabilities beyond those + limits after publication. Use one shared exact-template limits helper. Replace the mobile + assertion loop with collection assertions while preserving fractional-pixel tolerance. +- [x] Follow-up root check: 347 Viewer, 63 utils and 641 Node tests (one existing Node skip). +- [x] Public-limit corrections: packed fixture passes all 126 cases and GitHub CI passes on + `d8217df`. A further council found a signed-fallback authorization bypass when local publication + policy lags server-side revocation. Reproduce all three out-of-public-range cases red-first, + then require authorization for every signed redirect, regardless of local publication. +- [x] Final authorization fix: root check and all 126 packed browser cases pass. Follow-up council + found only callback-documentation ambiguity; document that signed compatibility renditions still + call the authorizer after publication and link the mixed-delivery explanation. +- Exact-head GitHub CI remains a live merge gate; see the current PR checks, not an older green head. + +## Cross-repository work + +- API2 #9057: recover a persisted signup when verification mail throws, without bypassing + verification or weakening the local recipient guard. Test false returns and exceptions. +- Content #5973: recover into verification instead of repeating signup; test pending-user + security boundaries, browser UX and the newly packed Viewer. +- Uppy does not participate in these four fixes. No npm publication, production deployment, + migration application or merge into main is authorized by this follow-up. + +Evidence: `/tmp/viewer-dx-fixes-20260917.x7BDzj/`. Original independent review and parent +reconciliation: `/tmp/viewer-dx-astra-20260917.AhRBGF/`. The assisted first-pixel timing was +not a clean signup benchmark; the mail exception was confirmed locally, not in production. diff --git a/knip.ts b/knip.ts index c398c1c9..f930dfbd 100644 --- a/knip.ts +++ b/knip.ts @@ -80,6 +80,7 @@ const config: KnipConfig = { 'clipanion', 'debug', 'dotenv', + 'execa', 'form-data', 'got', 'into-stream', @@ -90,6 +91,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..12497670 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,11 @@ "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/viewer 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", - "tsc:img": "yarn workspace @transloadit/img lint:ts", + "tsc:img": "yarn workspace @transloadit/viewer lint:ts", "tsc:node": "yarn tsc:utils && node ./node_modules/typescript/bin/tsc -b packages/node/tsconfig.build.json && chmod +x packages/node/dist/cli.js", "tsc:types": "yarn workspace @transloadit/types generate && node ./node_modules/typescript/bin/tsc -b packages/types/tsconfig.build.json", "tsc:utils": "yarn workspace @transloadit/utils lint:ts", diff --git a/packages/img/README.md b/packages/img/README.md index a01ad971..1fb209fc 100644 --- a/packages/img/README.md +++ b/packages/img/README.md @@ -1,207 +1,109 @@ -# `@transloadit/img` +# `@transloadit/viewer` -Responsive previews of Transloadit Storage objects, delivered through Smart CDN. +Responsive images from Transloadit Storage or your existing HTTP/S3 assets, 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 viewer/utils/node/types tarballs. +After release: `npm install @transloadit/viewer && npm install --save-dev @transloadit/node`. +pnpm: `pnpm add @transloadit/viewer && pnpm add -D @transloadit/node`; +Yarn: `yarn add @transloadit/viewer && 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' +```bash +npx transloadit auth login +npx transloadit storage store ./hero.jpg website/hero.jpg --public --hashed +``` -const authKey = process.env.TRANSLOADIT_KEY -const authSecret = process.env.TRANSLOADIT_SECRET -const workspace = process.env.TRANSLOADIT_WORKSPACE +`--public` publishes the directory recursively, including future uploads. In `next.config.ts`: -if (!authKey || !authSecret || !workspace) { - throw new Error('Transloadit image credentials are required') -} - -export const { Image } = createTransloaditImage({ - authKey, - authSecret, - storage: { allowedPathPrefixes: ['website/'] }, - workspace, -}) +```ts +import type { NextConfig } from 'next' +import { withTransloaditImages } from '@transloadit/viewer/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 { Image } from '@transloadit/viewer/next' export default function Page() { - return ( - A canal house - ) + return A canal house } ``` -`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. - -### Direct delivery +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 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. +## 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. +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). -### Authorized redirects +## Existing HTTP or S3 assets -Redirect delivery keeps markup stable and rechecks application access when the browser loads an -image: +Use a [compatible Template](./docs/reference.md#custom-templates) that pins your origin or bucket: ```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: - -```ts -export { storageRoute as GET } from '../../../lib/transloaditImage.tsx' +An oak chair ``` -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. +No Storage upload or catalog. Supply intrinsic dimensions and that workspace's server-only signing +key. Images shrink to fit their container by default; `layout="none"` leaves sizing to your CSS. +For private assets, authorize in the calling Server Component or use the per-request route +below, checking workspace, template and path. [Setup, metadata and defaults](./docs/reference.md#custom-templates). -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. +## Private -| 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 | +Wire `getSession` to your application's session and per-object permissions; it is not an SDK helper: -## 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 +```ts +// transloadit.authorize.ts, beside next.config.ts +import type { AuthorizeTransloaditImage } from '@transloadit/viewer/next/server' +import { transloaditStoragePreviewTemplate } from '@transloadit/viewer' +import { getSession } from './lib/authorization' +export const authorize: AuthorizeTransloaditImage = async ({ asset_id, request, template }) => + template === transloaditStoragePreviewTemplate && + asset_id !== undefined && (await getSession(request))?.canReadAsset(asset_id) === 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/viewer/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? Restore the committed catalog or [recover it from Storage](./docs/reference.md#recovery). -## 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) · [Version-pinned delivery](./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..92533b6f --- /dev/null +++ b/packages/img/docs/reference.md @@ -0,0 +1,987 @@ +# Images reference + +Start with the [Quickstart](../README.md). This reference covers policy, advanced layouts and operations. + +## Next.js plugin and catalog convention + +`Image` from `@transloadit/viewer/next` is an App Router Server Component. Wrap the existing +Next config with `withTransloaditImages(nextConfig)` from `@transloadit/viewer/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. + +The conventional Storage catalog and authorizer require the plugin. Explicit custom-template +props with server environment credentials work without it; the plugin adds shared workspace, +delivery and authorization configuration. A missing default catalog is allowed for template-only +apps; an explicitly selected missing catalog is an error. Restart dev when adding a first catalog. +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/viewer/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 workspace/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. API provenance is separate: development asset +IDs cannot be moved to production by changing the delivery host. 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/viewer/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 +`createImages(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, API provenance and delivery. This +offline page generation neither reads Storage nor binds existing assets to a saved login. +Credentialed init (publication or `--write-env`) and explicit `--endpoint` require a matching catalog +API origin; otherwise they refuse before any write. Select matching credentials or a separate +`--receipts` file for another environment. Custom CDN delivery hosts are independent from API +provenance and can be configured in the catalog or Next config plugin. +`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 `createImages` factory for shared catalogs +outside the app instead. + +## Custom Templates + +Use `Image workspace="my-shop" template="product-images"` for a compatible HTTP, S3 or other +customer-controlled import Template. `storage` and `template` are mutually exclusive. Templates +can be identified by name or ID; names are readable, IDs survive renaming, and neither pins the +Template's contents. No source alias registry is needed. + +Set `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET` for the selected workspace in +the server environment. The workspace default comes from +`withTransloaditImages(nextConfig, { workspace: 'my-shop' })`, the Storage catalog, or +`TRANSLOADIT_WORKSPACE`. An explicit `workspace` prop cannot borrow another workspace's catalog +or signing credentials. Use an explicit factory with its own key pair for another workspace. +The plugin's `workspace` is a project default, not a catalog rewrite: if Storage selection disagrees +with the catalog, rendering fails. Keep them aligned or explicitly select matching metadata. +With a `src` metadata object, `width={480}` sets the responsive display size independently of the +original dimensions. A template-only application needs neither the catalog nor the config plugin +unless it wants the plugin's default workspace, authorizer or delivery overrides. + +Custom Templates do not use the Storage catalog's geometry or public prefixes. Pass original +dimensions through `src={{ path, width, height }}` from trusted CMS/database metadata, or supply +intrinsic `width` and `height` alongside a string `src`. Rendering performs no metadata lookup +and never imports the original into Transloadit Storage. Paths must name one relative file, not +an origin, directory, multi-file list or traversal. Keep Templates and workspace selection trusted +application configuration, not unsanitized URL/search parameters. +This portable custom-Template mode rejects `%`, `?` and `#` in inputs: HTTP importers interpret +escapes, queries and fragments after a prefix authorization check, whereas S3/Storage keys are +literal. Do not pre-encode `src`. Ordinary Unicode names and spaces are allowed. The `storage` +mode retains literal-key behavior, including percent characters; it does not use an HTTP importer. + +An HTTP image Template can use these instructions. Replace the fixed origin with your own origin; +enable required signatures and disable step overrides in the Template settings: + +```json +{ + "steps": { + "imported": { + "robot": "/http/import", + "url": "https://assets.example.com/${fields.input}", + "max_file_size": 16777216 + }, + "resized": { + "robot": "/image/resize", + "use": "imported", + "width": "${fields.w}", + "height": "${fields.h}", + "format": "${fields.f}", + "quality": "${fields.q}", + "resize_strategy": "${fields.r}", + "background": "${fields.bg}", + "zoom": false + }, + "served": { + "robot": "/file/serve", + "use": "resized" + } + } +} +``` + +For S3, replace only `imported` with the following, using Template Credentials restricted to the +intended bucket and prefix. Keep bucket secrets in those credentials, never in JSX or the browser: + +```json +{ + "robot": "/s3/import", + "credentials": "product-bucket", + "path": "catalog/${fields.input}" +} +``` + +For that S3 example, `src="chairs/oak.jpg"` selects `catalog/chairs/oak.jpg`. The HTTP example +selects `https://assets.example.com/chairs/oak.jpg`. Configure bounded origin/bucket/path and +transformation policies; a valid image response alone does not establish those policies. In +development the HEAD probe warns when a Template ignores the requested output format. Verify +geometry, crops and transparency too; response headers alone cannot prove those properties. + +Signed direct rendering is request-time work and relies on authorization in the page that renders +the image. If the project has `transloadit.authorize.ts`, the same native redirect route is used; +its callback receives `{ workspace, template, path, request }`. Check permission for that full +asset identity. The README's Storage-only example deliberately refuses custom Templates. A +capability is bound to its workspace and Template: changing either URL selector cannot obtain a +new signed target. Already-issued targets remain usable until their expiry, as with Storage. + +For a reusable component with default workspace/Template and explicit prefix restrictions: + +```ts +import { createImages } from '@transloadit/viewer/next/server' + +export const { Image } = createImages({ + workspace: 'my-shop', + template: 'product-images', + allowedPathPrefixes: ['chairs/'], + delivery: 'direct', +}) +``` + +Import that bound `Image` in your page and supply `src`, its dimensions and presentation props; +the factory already selected the Template, so no `storage` or `template` prop is needed. Supply +an `authorize` callback and export the returned `imageRoute` for private per-request delivery. +The conventional component uses its default server-signing placeholder. If you need +`suspenseFallback`, use an explicit direct-delivery factory; redirect rendering never suspends for signing. +For multiple workspaces, create explicit factories with each workspace's own key pair. Environment +credentials are not a cross-workspace credential registry. + +Custom Templates default to signed delivery. Setting a Storage public prefix does not publish an +HTTP or S3 source. An explicit factory's `publicTemplate` override remains an advanced option only +when that Template independently enforces the intended unsigned source and transformation policy. +Never disable signatures on an unrestricted arbitrary-origin importer. + +### Migrating the unpublished image candidate + +Replace `@transloadit/img` imports with `@transloadit/viewer`, `StorageImage` with `Image storage`, +and `createStorageImages` with `createImages` (its bound component is `Image`, its route is +`imageRoute`). Regenerate the catalog's `.d.ts` with the matching CLI and update the config/route +imports together. There is no compatibility wrapper for this unpublished package. + +`TRANSLOADIT_WORKSPACE` no longer overrides an explicit factory or catalog workspace. Public +images use that catalog's identity without reading signing credentials. To point a staging app +elsewhere, use its own catalog/metadata or an explicit factory with the matching workspace and +credentials; changing only the environment variable must not reinterpret paths or publication policy. + +## 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. + +Every high-level Image source defaults to `constrained`, including Template string paths with +explicit intrinsic `width` and `height`. It shrinks to its container without stretching the original. +For a Template receipt, put intrinsic dimensions in `src` and pass `width` separately to set a +smaller responsive maximum. `layout="none"` opts out of generated responsive styles and sizes; +string-source dimensions still describe the original, not an independent crop or display box. + +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 +Your profile photo +``` + +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 +A canal house +``` + +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 +
+ A canal house +
+``` + +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 { AuthorizeTransloaditImage } from '@transloadit/viewer/next/server' +import { transloaditStoragePreviewTemplate } from '@transloadit/viewer' +import { authenticate, canReadStorageObject } from './lib/authorization' + +export const authorize: AuthorizeTransloaditImage = async ({ path, request, template }) => { + if (template !== transloaditStoragePreviewTemplate) return false + 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. +This example serves one configured Storage workspace. When adding custom Templates, authorize +their workspace, template and path explicitly; a matching Storage path is not the same asset. +Export in `app/api/storage-images/route.ts` (prefix source paths with `src/` if your app uses it): + +```ts +export { GET, HEAD } from '@transloadit/viewer/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 a key imported with `auth login --stdin`; browser/device-login keys are refused because +`auth logout` revokes them. Prefer a separate application key configured in your app environment. +If scaffold creation fails after the catalog is initialized, the catalog and its declarations stay +on disk: another upload may already have added receipts. Incomplete legacy catalog entries are +excluded from conventional rendering with a path-specific diagnostic, not silently deleted. +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 { createImages } from '@transloadit/viewer/next/server' +import catalog from '../transloadit.images.json' +import { authorize } from '../transloadit.authorize' + +export const { Image, imageRoute } = createImages({ ...catalog, authorize, cacheMaxAge: '1m' }) +``` + +Its route exports `imageRoute as GET, imageRoute 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. Newly rendered published +paths emit direct unsigned URLs, with zero application image requests; private paths still emit +capabilities and use the authorizer. Old private capabilities exceeding public rendition limits +still invoke `authorize` after publication; see [compatibility redirects](#redirect-lifetime-and-caching). +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 native catalog 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 +A canal houseImage 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, apiOrigin, public, images }` and optional non-production `delivery`. +An explicit factory workspace takes precedence over `TRANSLOADIT_WORKSPACE`. `Image storage` +keeps its catalog's identity; a conflicting `workspace` prop or plugin default is rejected before +using that catalog's paths, metadata or publication policy. Environment signing credentials bound +to another workspace are rejected; use a factory with an explicit matching key pair instead. +The factory does not read the CLI's saved credentials file. Private `--write-env` can copy an +imported application key, never a browser/device-login key, into 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. Moving an app to production +requires uploading or recovering its production assets into a separate catalog with production IDs; +changing a delivery URL cannot migrate assets. That production catalog needs no development delivery +block or plugin/factory override. 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 +`createImages(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 { createImages } from '@transloadit/viewer/next/server' +import catalog from '../transloadit.images.json' + +export const { Image } = createImages({ + ...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 asset/version, receipt 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, bounding the lifetime of the +cached public-policy decision. The capability still selects its original retained version. +New public markup uses direct version-pinned CDN URLs and does not take this compatibility route. +Renditions beyond the public Built-in's dimension or quality limits require application authorization +and keep their compatible signed delivery and private redirect caching; publishing does not silently +resize existing markup. + +### 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. Storage receipts pin an `asset_id` and `version_id`: +even a cold request after an overwrite reads that exact retained version, never the replacement. +Renaming the asset does not change its identity. Deletion or version removal fails closed; there +is no fallback to the current path. Hashed filenames remain useful for repository organization: +`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. CLI writes record catalog-level `apiOrigin`, +including publication before the first upload, and each upload records its origin too. A development +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 another environment. For a +legacy receipt with a recorded API origin, run `storage receipts sync` against that endpoint to +recover version identity before retrying. If its origin is missing, recover into a new `--receipts` +file, review the recovered paths and replace the old catalog; sync never guesses its environment. +Commit the catalog: without that evidence the CLI cannot +prove a remote conflict is the same object. Recover 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 URL input is the asset ID; `v` is its actual version ID, not a digest or arbitrary cache tag. +Public version-pinned responses use `public, max-age=31536000, s-maxage=31536000, immutable`. +Both IDs are required even when no checksum is available. Refreshing a catalog selects the current +version for new markup; previously minted URLs still select their original retained bytes. +Public access is checked against the asset's current location and current public-prefix policy. +Moving it into a private directory stops uncached public delivery, including historical versions; +already-cached or downloaded bytes cannot be recalled. Production Bunny cache hits/cost remain 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 { Image } = createImages({ + 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. + +The asset/version contract bumps private capabilities to payload version 2: they seal both IDs +alongside the authorization path. Rebuild old markup once during this migration and recover old +path-only catalogs with `storage receipts sync`. Later compatible Built-in pipeline upgrades do not +change the sealed asset/version. For private applications that rename assets, authorize using the +provided `asset_id` and `version_id`; the path is the receipt's location when markup was rendered, +not a fresh catalog lookup. Custom Templates retain their path input contract. + +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 `imageRoute` to the result. + + +### Format, width and lifetime policy + +Private delivery pins `builtin/storage-preview@0.0.3`; public delivery pins +`builtin/public-preview@0.0.2`, which wraps `builtin/storage-preview@0.0.3`: 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 catalog 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 filenames](#cache-and-markup-cost) for repository organization; delivery pins the actual +asset version, and cached bytes can outlive deletion or revocation. 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 + +If no usable receipt comes back, inspect with `storage ls` and recover with `storage receipts sync`, +using the same catalog. These commands require the native catalog API deployment; until then, +restore the committed catalog or use the +[Assembly receipt recovery API](#images-uploaded-by-your-users) with trusted upload metadata. + +`storage ls website/` reads `GET /dam/assets` with an Auth Key scoped to `dam:read` or `dam:write`. +It uses signed native metadata pages of at most 500 entries, without S3, per-file HEAD requests or +Assemblies. `--workspace` must match the Workspace discovered from those credentials. An empty +catalog still identifies its Workspace. Each request has a 60-second deadline covering headers +and response-body reads; there are no implicit retries. Failed syncs release the catalog lock and +leave the existing catalog intact. `--json` includes asset/version IDs and available checksums. +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. +These native reads do not depend on `API2_STORAGE_S3_ENABLED`. A missing route means the selected +API deployment is too old; HTTP 403 means access was refused. Check the endpoint and key scope. + +Recover or refresh a rendering catalog without re-uploading or downloading originals: + +```console +npx transloadit storage receipts sync website/ +``` + +This uses native catalog pages with the same `dam: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. +The server returns the same canonical shape as storing: `workspace`, `asset_id`, `version_id`, +final `path`, `size`, `mime`, available `md5hash`/`sha256`, and version-specific `width`/`height`. +Sync records the verified `apiOrigin` on the catalog and every recovered image and refuses to mix environments. +Unbound legacy catalogs must be recovered into a new `--receipts` file and reviewed before replacing +the old catalog. A custom delivery host is preserved separately, never treated as API provenance. +Sync recovers real version identities, not a path-only approximation. Local `source`, +`thumbhash` and `hasAlpha` survive only for the same Workspace, asset and retained version. +A fresh sync has no original bytes and cannot reconstruct ThumbHash or alpha metadata; those +optional fields remain absent. Changed versions drop stale local evidence even if their MD5 matches. + +Sync adds or refreshes matching paths and never prunes unmatched entries. Any missing/invalid +dimensions, failed page 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, use `client.getStoredAssemblyResults({ assemblyId: upload.assemblyId, +workspace: upload.workspace })`. It fetches the authoritative completed Assembly and returns all +canonical Storage assets with their producing step, result ID and original-input IDs. Register +those idempotently under your server-owned upload/owner record; it is not an ownership check or +a replacement for trusted byte expectations when those are required. See the Node SDK's +[batch, native management and original delivery recipe](../../node/README.md#read-and-reuse-stored-assets). +Images require positive `width` and `height` before passing their saved asset to the factory below; +videos/audio do not need fake image dimensions. Generate/store posters or previews separately. +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. + +For fullscreen images, size the preview for its real viewport. For original downloads or native +video/audio playback, authorize the saved asset/version in an application route and redirect to +`client.getStoredAssetUrl(asset, { download: true })` (omit `download` for inline playback). +The browser reads bytes directly from the CDN; the Node SDK's recipe covers expiry, Range and +attachment filenames. Image previews, compatible playback renditions and originals are separate +outputs, not interchangeable URLs. +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 { createImages } from '@transloadit/viewer/next/server' +import { getSession } from '../lib/authorization' + +export const { Image, imageRoute } = createImages({ + workspace: 'your-workspace', + allowedPathPrefixes: ['uploads/'], + route: '/api/upload-images', + authorize: async ({ asset_id, request }) => + asset_id !== undefined && (await getSession(request))?.canReadAsset(asset_id) === true, +}) +``` + +```ts +// app/api/upload-images/route.ts +export { imageRoute as GET, imageRoute as HEAD } from '../../upload-images' +``` + +`getSession`, `canReadAsset` and `getAuthorizedImage` below are your application's helpers, not SDK helpers. +`canReadAsset(asset_id)` checks the current user's permission for that stable asset identity, +including after a rename. Being logged in or matching `uploads/` is insufficient. 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 { Image } 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.description} +} +``` + +`savedImage` is the application's validated database record. Owner metadata is never forwarded; +asset/version IDs identify the bytes in public and signed CDN URLs. IDs are not credentials. +Private redirect capabilities seal the reference until the route authorizes it. See +[upload/overwrite guidance](#receipt-integrity-and-recovery) for pinning and recovery. +The browser never needs the Assembly secret, Smart CDN secret, or a render-time metadata lookup. + +### Credentials and framework adapters + +`createImages({ 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/viewer` exposes `createTransloaditImageModel` and serializable model types for other +framework adapters. `@transloadit/viewer/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..9a074f36 100644 --- a/packages/img/package.json +++ b/packages/img/package.json @@ -1,7 +1,7 @@ { - "name": "@transloadit/img", + "name": "@transloadit/viewer", "version": "0.0.0", - "description": "Responsive Transloadit Storage previews powered by Smart CDN", + "description": "Native responsive images from Transloadit Storage and Smart CDN Templates", "private": true, "type": "module", "license": "MIT", @@ -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..b423b2ef --- /dev/null +++ b/packages/img/src/imageSource.ts @@ -0,0 +1,134 @@ +import { validateStoragePath } from '@transloadit/utils' + +/** Workspace-scoped identity of one retained Storage version, independent of its path. */ +export interface TransloaditStorageReference { + readonly workspace: string + readonly asset_id: string + readonly version_id: string +} + +/** Saved geometry for Storage receipts or customer-controlled HTTP/S3 Template inputs. */ +export interface TransloaditImageSource extends Partial { + 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 +} + +/** Checks the canonical API2 ID spelling without adding a schema library to browser models. */ +export function isStorageIdentifier(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/u.test(value) +} + +/** Require a complete pinned receipt before constructing any Storage delivery URL. */ +export function getStorageImageReference( + source: Partial, + workspace?: string, +): TransloaditStorageReference { + const { asset_id, version_id, workspace: receiptWorkspace } = source + if ( + !isStorageIdentifier(asset_id) || + !isStorageIdentifier(version_id) || + typeof receiptWorkspace !== 'string' || + receiptWorkspace.length === 0 + ) { + throw new TypeError( + 'Storage needs a version-pinned receipt with workspace, asset_id and version_id. Run transloadit storage receipts sync for this image prefix.', + ) + } + if (workspace !== undefined && receiptWorkspace !== workspace) { + throw new TypeError( + `Storage receipt belongs to Workspace ${receiptWorkspace}, not ${workspace}`, + ) + } + return { asset_id, version_id, workspace: receiptWorkspace } +} + +/** 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 + let reference: Partial = {} + 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 ('asset_id' in src || 'version_id' in src || 'workspace' in src) { + const asset_id = 'asset_id' in src ? src.asset_id : undefined + const version_id = 'version_id' in src ? src.version_id : undefined + const workspace = 'workspace' in src ? src.workspace : undefined + if ( + !isStorageIdentifier(asset_id) || + !isStorageIdentifier(version_id) || + typeof workspace !== 'string' || + workspace.length === 0 + ) { + throw new TypeError( + 'Storage receipt identity is incomplete. Run transloadit storage receipts sync for this image prefix.', + ) + } + reference = { asset_id, version_id, workspace } + } + } + 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 { + ...reference, + 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..c4e75b47 100644 --- a/packages/img/src/index.ts +++ b/packages/img/src/index.ts @@ -1,17 +1,39 @@ -import type { SignSmartCdnImageRequest, SmartCdnImageFormat } from '@transloadit/utils' +import type { SmartCdnImageFormat, SmartCdnImageSignRequest } from '@transloadit/utils' + +import type { TransloaditImageSource, TransloaditImageSourceProps } from './imageSource.ts' import { + getSmartCdnImageLimits, resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, } from '@transloadit/utils' -import { validateStoragePath } from './storagePath.ts' +import { isOpaqueImageBackground, transparentImageBackground } from './imageBackground.ts' +import { getStorageImageReference, 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.3' +/** Unsigned Built-in, served only under a server-declared public Storage prefix. */ +export const transloaditPublicStoragePreviewTemplate = 'builtin/public-preview@0.0.2' + +/** Only these pinned Built-ins accept an asset ID and a real version ID. */ +export function isVersionedStorageTemplate(template: string): boolean { + return ( + template === transloaditStoragePreviewTemplate || + template === transloaditPublicStoragePreviewTemplate + ) +} + +/** A rendition resolver receives the snapshotted source as context for authorization routes. */ +export type ResolveTransloaditImage = ( + request: SmartCdnImageSignRequest, + source: TransloaditImageSource, +) => string const defaultFallbackQuality = 75 const defaultResponsiveImageWidths: readonly number[] = [320, 640, 960, 1280, 1920, 2560, 3840] const minimumMillisecondTimestamp = 1_000_000_000_000 @@ -40,30 +62,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.3`. */ 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 +111,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,72 +132,117 @@ 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: ResolveTransloaditImage, ): TransloaditImageModel { + const source = snapshotImageSource(options) + const { path: src, width, height } = source 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 + const reference = isVersionedStorageTemplate(template) + ? getStorageImageReference(source) + : undefined + const input = reference?.asset_id ?? src + const { maxDimension, maxQuality } = getSmartCdnImageLimits(template) 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: { - f: format, - h: getStorageHeight(candidateWidth, width, height), - q: quality, - r: 'pad', - w: candidateWidth, + url: sign( + { + expiresAt, + input, + template, + urlParams: { + ...(reference === undefined ? {} : { v: reference.version_id }), + bg: transparentImageBackground, + f: format, + h: getStorageHeight(candidateWidth, ratioWidth, ratioHeight), + q: quality, + r: cropAspectRatio === undefined ? 'pad' : 'fillcrop', + w: candidateWidth, + }, }, - }), + source, + ), width: candidateWidth, })), format, })) - const fallbackWidth = Math.min(width, maximumWidth) - const fallbackUrl = sign({ - expiresAt, - input: src, - template, - urlParams: { - f: 'jpg', - h: getStorageHeight(fallbackWidth, width, height), - q: fallbackQuality, - r: 'pad', - w: fallbackWidth, + const fallbackWidth = Math.min(requestedFallbackWidth ?? width, Math.max(...widths)) + const fallbackUrl = sign( + { + expiresAt, + input, + template, + urlParams: { + ...(reference === undefined ? {} : { v: reference.version_id }), + bg: fallbackBackground, + f: 'jpg', + h: getStorageHeight(fallbackWidth, ratioWidth, ratioHeight), + q: fallbackQuality, + r: cropAspectRatio === undefined ? 'pad' : 'fillcrop', + w: fallbackWidth, + }, }, - }) + source, + ) return { expiresAt, fallbackUrl, sources } } 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..b38d440b --- /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( + `[Image] 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..3e660cde --- /dev/null +++ b/packages/img/src/next/authorize.ts @@ -0,0 +1,4 @@ +import type { AuthorizeTransloaditImage } from './server.tsx' + +// Only the plugin may replace this with application code; request data never selects a module. +export const authorize: AuthorizeTransloaditImage | undefined = undefined diff --git a/packages/img/src/next/catalog.ts b/packages/img/src/next/catalog.ts new file mode 100644 index 00000000..9244cb52 --- /dev/null +++ b/packages/img/src/next/catalog.ts @@ -0,0 +1,18 @@ +import type { StorageImageCatalog } from './layout.ts' +import type { ImageConfiguration } 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..5df568ad --- /dev/null +++ b/packages/img/src/next/config.ts @@ -0,0 +1,114 @@ +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 + /** Default workspace; selecting another Storage catalog's workspace is an error, never a rebind. */ + workspace?: 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) + const hasCatalog = statSync(catalog, { throwIfNoEntry: false })?.isFile() === true + if (!hasCatalog && options.catalog !== undefined) + 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({ + ...(options.workspace === undefined ? {} : { workspace: options.workspace }), + ...(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 = { + ...(hasCatalog ? { '@transloadit/viewer/next/catalog': catalogPath } : {}), + '@transloadit/viewer/next/options': projectPath(configuration), + ...(statSync(authorize, { throwIfNoEntry: false })?.isFile() + ? { '@transloadit/viewer/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..21a18e63 --- /dev/null +++ b/packages/img/src/next/diagnostics.ts @@ -0,0 +1,119 @@ +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/')) { + const format = target.searchParams.get('f') + const expectedType = + format === 'jpg' + ? 'image/jpeg' + : format === 'webp' || format === 'avif' || format === 'png' + ? `image/${format}` + : undefined + const actualType = response.headers.get('content-type')?.split(';')[0]?.trim() + if (expectedType !== undefined && actualType !== expectedType) { + console.warn( + `[Image] Development HEAD ${safeUrl} returned ${actualType}, expected ${expectedType}. The configured Template must honor the requested image format (f); use the compatible image Template recipe.`, + ) + return `${summary}; received ${actualType}, expected ${expectedType}` + } + if ( + publicPrefix !== undefined && + response.headers.get('cache-control')?.includes('immutable') + ) + console.info( + `[Image] 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 source path exists, 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(`[Image] 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( + `[Image] 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( + `[Image] 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..33473d33 --- /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( + '[Image] 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..a7cbd8f9 100644 --- a/packages/img/src/next/index.tsx +++ b/packages/img/src/next/index.tsx @@ -5,42 +5,76 @@ 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 + } +> + +/** Storage catalog paths or explicit custom-template inputs; workspace can use project defaults. */ +export type ImageProps = { workspace?: string } & ( + | ({ storage: true; template?: never } & StorageImageProps) + | ({ storage?: never; template: string } & TransloaditRedirectImageProps) +) + +/** The server implementation selects Storage Built-ins or an explicitly trusted custom Template. */ +export function Image(_props: ImageProps): ReactNode { + throw new Error( + 'Image 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 +88,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 +120,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 +130,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( + '[Image] 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( + '[Image] 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..e50c6655 --- /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> + +/** All 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 ?? '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..d3efc799 --- /dev/null +++ b/packages/img/src/next/options.ts @@ -0,0 +1,16 @@ +import type { StorageImageDelivery } from './catalog.ts' + +/** Build-time overrides; application keys always stay in the server environment. */ +export interface StorageImageProjectOptions { + /** Default workspace; selecting another Storage catalog's workspace is an error, never a rebind. */ + workspace?: string + /** 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..f2c24e7f --- /dev/null +++ b/packages/img/src/next/project.ts @@ -0,0 +1,89 @@ +import 'server-only' + +import type { StorageImageCatalog } from './layout.ts' +import type { TransloaditImageIntegration, TransloaditRedirectImageIntegration } from './server.tsx' + +import { authorize } from '@transloadit/viewer/next/authorize' +import catalog from '@transloadit/viewer/next/catalog' +import options from '@transloadit/viewer/next/options' +import { cache } from 'react' + +import { getStorageImageReference, snapshotImageSource } from '../imageSource.ts' +import { diagnosePublicPolicy } from './diagnostics.ts' +import { createImages } 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(workspace?: string): ProjectIntegration { + if (catalog === undefined) + throw new Error( + 'No bundled Storage catalog (transloadit.images.json). Run transloadit storage store to create it, and ensure withTransloaditImages() from @transloadit/viewer/next/config wraps next.config.ts. Restart next dev after creating the first catalog, or use createImages with an explicit catalog.', + ) + const selectedWorkspace = workspace ?? options.workspace ?? catalog.workspace + // A project default must not reinterpret a catalog. Use matching metadata or an explicit factory. + if (selectedWorkspace !== catalog.workspace) + throw new TypeError( + `Storage workspace conflicts with catalog workspace ${JSON.stringify(catalog.workspace)}: received ${JSON.stringify(selectedWorkspace)}. Use matching metadata or an explicit factory for another workspace.`, + ) + if (integration !== undefined) return integration + diagnosePublicPolicy(catalog, options.diagnosticsId) + const images = Object.fromEntries( + Object.entries(catalog.images).flatMap(([path, value]) => { + try { + const receipt = snapshotImageSource({ src: value }) + getStorageImageReference(receipt, selectedWorkspace) + if (path !== receipt.path) throw new TypeError('Mismatched catalog path') + return [[path, receipt]] + } catch { + // The CLI preserves old recovery entries but excludes them from its type registration too. + console.warn( + `[Image] Skipping incomplete catalog image ${JSON.stringify(path)}. Run transloadit storage receipts sync for its prefix or replace that receipt before using it.`, + ) + return [] + } + }), + ) + integration = createImages({ + // Catalog writers preserve application metadata. Only these fields configure delivery. + workspace: catalog.workspace, + images, + public: catalog.public, + baseUrl: options.delivery?.baseUrl ?? catalog.delivery?.baseUrl, + urlParams: options.delivery?.urlParams ?? catalog.delivery?.urlParams, + authorize, + ...(authorize === undefined ? {} : { basePath: options.basePath }), + }) + return integration +} + +/** Request-scoped reuse: caller-selected templates never accumulate in a process-global registry. */ +// Development diagnostics are per integration too; bounded lifetime is preferable to retaining +// arbitrary template/path combinations merely to suppress warnings across requests. +export const getProjectTemplateImages = cache( + ( + template: string, + explicitWorkspace?: string, + ): TransloaditImageIntegration | TransloaditRedirectImageIntegration => { + const defaultWorkspace = + options.workspace ?? catalog?.workspace ?? process.env.TRANSLOADIT_WORKSPACE + const workspace = explicitWorkspace ?? defaultWorkspace + if (defaultWorkspace !== undefined && workspace !== defaultWorkspace) + throw new TypeError( + 'Signing credentials are bound to the configured workspace. Use createImages with explicit workspace credentials for a different workspace.', + ) + return createImages({ + // Template inputs have their own metadata and publication policy, not the Storage catalog's. + workspace, + template, + allowWorkspaceRoot: true, + ...options.delivery, + delivery: 'direct', + authorize, + ...(authorize === undefined ? {} : { basePath: options.basePath }), + }) + }, +) diff --git a/packages/img/src/next/react-server.tsx b/packages/img/src/next/react-server.tsx new file mode 100644 index 00000000..6a46e451 --- /dev/null +++ b/packages/img/src/next/react-server.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' + +import type { ImageProps, StorageImageProps } from './index.tsx' + +import { statSync } from 'node:fs' + +import { authorize } from '@transloadit/viewer/next/authorize' +import options from '@transloadit/viewer/next/options' + +import { getProjectImages, getProjectTemplateImages } from './project.ts' + +export type { + ImageProps, + RegisteredStorageImages, + StorageImageProps, + TransloaditImageLayoutProps, + TransloaditImagePresentationProps, + TransloaditPictureProps, +} from './index.tsx' + +export { TransloaditPicture } from './index.tsx' + +/** Render Storage or a compatible custom Template without exposing keys or selecting an alias. */ +export function Image(props: ImageProps): ReactNode { + if (props.storage === true) { + if (props.template !== undefined) throw new TypeError('Choose storage or template, not both') + const { storage, template, workspace, ...image } = props + return renderStorageImage(image, workspace) + } + if (props.storage !== undefined || typeof props.template !== 'string') + throw new TypeError('Choose storage or an explicit image template') + const { storage, template, workspace, ...image } = props + assertBundledAuthorizer() + return getProjectTemplateImages(template, workspace).Image(image) +} + +function assertBundledAuthorizer(cause?: unknown): void { + // Never load an authorizer at runtime; config must bundle it and its dependencies together. + if ( + process.env.NODE_ENV === 'development' && + authorize === undefined && + 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 }, + ) +} + +function renderStorageImage(props: StorageImageProps, workspace?: string): ReactNode { + try { + return getProjectImages(workspace).Image(props) + } catch (error) { + if ( + error instanceof TypeError && + error.message === "Private images require authorize or delivery: 'direct'" + ) + assertBundledAuthorizer(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..c02c64fb --- /dev/null +++ b/packages/img/src/next/route.ts @@ -0,0 +1,35 @@ +import 'server-only' + +import catalog from '@transloadit/viewer/next/catalog' +import options from '@transloadit/viewer/next/options' + +import { getProjectImages, getProjectTemplateImages } from './project.ts' +import { isImageSourceSelector } from './source.ts' + +/** Re-export at app/api/storage-images/route.ts; private requests always consult authorize. */ +export async function GET(request: Request): Promise { + const url = new URL(request.url) + const templates = url.searchParams.getAll('template') + const workspaces = url.searchParams.getAll('workspace') + const workspace = workspaces[0] + const template = templates[0] + const defaultWorkspace = + options.workspace ?? catalog?.workspace ?? process.env.TRANSLOADIT_WORKSPACE + if ( + templates.length > 1 || + workspaces.length > 1 || + (template === undefined) !== (workspace === undefined) || + (template !== undefined && !isImageSourceSelector(template)) || + (workspace !== undefined && !isImageSourceSelector(workspace)) || + (workspace !== undefined && defaultWorkspace !== undefined && workspace !== defaultWorkspace) || + (template === undefined && catalog === undefined) + ) + return new Response(null, { status: 404, headers: { 'Cache-Control': 'private, no-store' } }) + const integration = + template === undefined ? getProjectImages() : getProjectTemplateImages(template, workspace) + if (!('imageRoute' in integration) || typeof integration.imageRoute !== 'function') + return new Response(null, { status: 404, headers: { 'Cache-Control': 'private, no-store' } }) + return await integration.imageRoute(request) +} + +export { GET as HEAD } diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx index 35b27329..f7c2c870 100644 --- a/packages/img/src/next/server.tsx +++ b/packages/img/src/next/server.tsx @@ -3,150 +3,272 @@ import 'server-only' import type { SmartCdnUrlParams } from '@transloadit/utils/node' import type { ReactNode } from 'react' +import type { TransloaditImageSource } from '../imageSource.ts' import type { + ResolveTransloaditImage, SmartCdnImageSignRequest, 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 { + getSmartCdnImageLimits, + 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 { + getStorageImageReference, + isStorageIdentifier, + snapshotImageSource, +} from '../imageSource.ts' +import { + createTransloaditImageModel, + isVersionedStorageTemplate, + 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' +import { isImageSourceSelector } from './source.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 const storageCapabilityMaximumLength = 4096 const storageCapabilityMinimumBytes = storageCapabilityAuthenticationBytes + 1 const storageCapabilityPattern = /^[A-Za-z0-9_-]+$/ -const storageCapabilityVersion = 1 +const storageCapabilityVersion = 2 +// A package rename is not a capability-protocol change; preserve existing Storage route keys. const storageRouteKeyDomain = '@transloadit/img/storage-route/v1' /** Values available to application authorization before a Storage redirect is issued. */ -export interface TransloaditStorageAuthorizationContext { +export interface TransloaditImageAuthorizationContext { path: string + /** Pinned Storage identity; absent for customer HTTP/S3 Template inputs. */ + asset_id?: string + version_id?: string request: Request + workspace: string + template: string } -/** Application authorization for one exact private Storage object. */ -export type AuthorizeTransloaditStorageImage = ( - context: TransloaditStorageAuthorizationContext, +/** + * Return true to authorize a signed rendition. Usually the object is private; legacy capabilities + * beyond public rendition limits still need authorization after publication. Thrown errors propagate. + */ +export type AuthorizeTransloaditImage = ( + context: TransloaditImageAuthorizationContext, ) => boolean | Promise /** Request-authorized, byte-pass-through-free Storage delivery through a local route. */ -export interface TransloaditStorageRedirectDelivery { - authorize: AuthorizeTransloaditStorageImage +export interface TransloaditImageRedirectDelivery { + authorize: AuthorizeTransloaditImage + /** Server-declared public directories: use unsigned, direct CDN delivery. */ + public?: readonly string[] /** Next.js `basePath` prepended only to browser-facing route URLs. */ basePath?: string - /** Internal App Router path that exports `storageRoute`, for example `/api/private-images`. */ + /** Opt-in browser caching; capped at the rotation interval. Delays reauthorization. */ + cacheMaxAgeMs?: number + /** Internal App Router path that exports `imageRoute`, 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 + /** Explicit workspace; otherwise resolved from TRANSLOADIT_WORKSPACE 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?: AuthorizeTransloaditImage + /** 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 ImageConfiguration = + 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 AuthorizedImagesConfiguration< + Catalog extends StorageImageCatalog | undefined = undefined, +> = ImageConfiguration & { + authorize: AuthorizeTransloaditImage } -/** 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 +export type TransloaditImageRoute = (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, +> { + Image: TransloaditImageComponent } /** Redirect-delivery integration with a route handler for private Storage images. */ -export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration { - storageRoute: TransloaditStorageRoute +export interface TransloaditRedirectImageIntegration< + Catalog extends StorageImageCatalog | undefined = undefined, +> { + Image: (props: TransloaditRedirectImageProps) => ReactNode + imageRoute: TransloaditImageRoute } interface ResolvedStoragePolicy { allowedPathPrefixes: readonly string[] - delivery: 'direct' | TransloaditStorageRedirectDelivery - expiresInMs: number - rotationIntervalMs: number + allowedPaths: ReadonlySet + customTemplate: boolean + delivery: 'direct' | TransloaditImageRedirectDelivery + images?: StorageImageCatalog + lifetime?: number + public: readonly string[] + rotationIntervalMs?: number } interface ResolvedStorageCapabilityPolicy { context: string - delivery: TransloaditStorageRedirectDelivery + delivery: TransloaditImageRedirectDelivery key: Buffer } interface StorageImageTransform { + asset_id?: string + version_id?: string + 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`) } @@ -183,8 +305,8 @@ function validateBaseUrl(baseUrl: string | undefined): void { function validateTemplate(template: string | undefined, name: string): void { if (template === undefined) return - if (typeof template !== 'string' || template === '' || template.trim() !== template) { - throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`) + if (!isImageSourceSelector(template)) { + throw new TypeError(`${name} must be 1–256 characters without surrounding whitespace`) } } @@ -197,7 +319,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 +334,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 === '' || @@ -230,7 +350,7 @@ function validateStorageBasePath(basePath: string | undefined): void { if (parsed.origin !== 'https://transloadit.invalid' || parsed.pathname !== basePath) throw error } -function getBrowserStorageRoute(delivery: TransloaditStorageRedirectDelivery): string { +function getBrowserStorageRoute(delivery: TransloaditImageRedirectDelivery): string { return `${delivery.basePath ?? ''}${delivery.route}` } @@ -238,7 +358,7 @@ function removeTrailingSlash(path: string): string { return path === '/' || !path.endsWith('/') ? path : path.slice(0, -1) } -function matchesStorageRoute(path: string, delivery: TransloaditStorageRedirectDelivery): boolean { +function matchesStorageRoute(path: string, delivery: TransloaditImageRedirectDelivery): boolean { const normalized = removeTrailingSlash(path) return ( normalized === removeTrailingSlash(delivery.route) || @@ -246,59 +366,193 @@ 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: ImageConfiguration, ): 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)) + } + } + 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') } - validateStorageRoute(delivery.route) - validateStorageBasePath(delivery.basePath) - if (typeof delivery.authorize !== 'function') { - throw new TypeError('storage.delivery.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, + customTemplate: configuration.template !== undefined, + 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))) { + // Storage keys are literal. A custom importer can interpret URL escapes, queries or fragments + // after the application authorized a prefix, so that portable input mode must reject them. + if (policy.customTemplate && /[%?#]/.test(path)) + throw new TypeError( + 'Template inputs must be literal relative paths without URL escapes, queries or fragments', + ) + 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 +568,203 @@ 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 (!isVersionedStorageTemplate(template)) return parameters + const allowed = new Set(['bg', 'f', 'q', 'r', 'w', 'h', 'v', 'cdn']) + for (const name of Object.keys(parameters)) { + if (!allowed.has(name)) + throw new TypeError( + `urlParams parameter ${name} is not supported by the selected Storage Built-in`, + ) + } + 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 + ? `[Image] ${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 + ? `[Image] ${JSON.stringify(props.source.path)}: transparent image: no blur placeholder.` + : `[Image] ${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, + source: TransloaditImageSource, +): 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 { + ...(isVersionedStorageTemplate(request.template) + ? { asset_id: request.input, version_id: getStorageImageReference(source).version_id } + : {}), + background, + format, + height, + path: source.path, + quality, + width, + ...(strategy === 'fillcrop' ? { strategy } : {}), + } } function createStorageRouteKey(authSecret: string, workspace: string): Buffer { @@ -388,14 +772,15 @@ function createStorageRouteKey(authSecret: string, workspace: string): Buffer { } function getStorageCapabilityContext( - delivery: TransloaditStorageRedirectDelivery, - template: string, + delivery: TransloaditImageRedirectDelivery, + customTemplate: string | undefined, workspace: string, ): string { return JSON.stringify([ storageRouteKeyDomain, + storageCapabilityVersion, workspace, - template, + customTemplate ?? null, delivery.route, getBrowserStorageRoute(delivery), ]) @@ -415,12 +800,14 @@ function encryptStorageCapability( function getStorageRouteUrl( context: string, - delivery: TransloaditStorageRedirectDelivery, + delivery: TransloaditImageRedirectDelivery, key: Buffer, - request: SmartCdnImageSignRequest, + request: Omit, + image: TransloaditImageSource, + source?: { workspace: string; template: string }, ): string { - const capability = encryptStorageCapability(context, key, getStorageTransform(request)) - return `${getBrowserStorageRoute(delivery)}?${new URLSearchParams({ cap: capability })}` + const capability = encryptStorageCapability(context, key, getStorageTransform(request, image)) + return `${getBrowserStorageRoute(delivery)}?${new URLSearchParams({ cap: capability, ...source })}` } function isRecord(value: unknown): value is Record { @@ -433,9 +820,17 @@ 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 { asset_id, version_id, background, format, height, path, quality, width, strategy } = + payload if ( + ((asset_id !== undefined || version_id !== undefined) && + (!isStorageIdentifier(asset_id) || !isStorageIdentifier(version_id))) || !isStorageRouteFormat(format) || + (background !== undefined && + (format === 'jpg' + ? !isOpaqueImageBackground(background) + : background !== transparentImageBackground)) || + (strategy !== undefined && strategy !== 'fillcrop') || typeof height !== 'number' || !Number.isInteger(height) || height < 1 || @@ -453,7 +848,18 @@ function getStorageTransformFromPayload(payload: unknown): StorageImageTransform return undefined } validateStoragePath(path) - return { format, height, path, quality, width } + return { + ...(typeof asset_id === 'string' && typeof version_id === 'string' + ? { asset_id, version_id } + : {}), + ...(typeof background === 'string' ? { background } : {}), + format, + height, + path, + quality, + width, + ...(strategy === 'fillcrop' ? { strategy } : {}), + } } function decryptStorageCapability( @@ -489,28 +895,61 @@ function parseStorageRouteTransform( url: URL, context: string, key: Buffer, + source?: { workspace: string; template: string }, ): StorageImageTransform | undefined { const parameters = [...url.searchParams.keys()] - if (parameters.length !== 1 || url.searchParams.getAll('cap').length !== 1) return undefined + if (parameters.length !== (source === undefined ? 1 : 3)) return undefined + if (url.searchParams.getAll('cap').length !== 1) return undefined + if ( + source !== undefined && + (url.searchParams.getAll('workspace').length !== 1 || + url.searchParams.get('workspace') !== source.workspace || + url.searchParams.getAll('template').length !== 1 || + url.searchParams.get('template') !== source.template) + ) + return undefined 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, + delivery: TransloaditImageRedirectDelivery, key: Buffer, policy: ResolvedStoragePolicy, sign: (request: SmartCdnImageSignRequest) => string, template: string, -): TransloaditStorageRoute { - return async function storageRoute(request: Request): Promise { + diagnose: DiagnoseStorageImage | undefined, + buildPublicUrl: (request: Omit) => string, + source: { workspace: string; customTemplate?: string; publicTemplate: string }, +): TransloaditImageRoute { + const publicLimits = getSmartCdnImageLimits(source.publicTemplate) + 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 && + source.customTemplate === undefined && + !policy.public.some((prefix) => path.startsWith(prefix)) + ? ` Storage path ${JSON.stringify(path)} is not under a public prefix in the current image configuration. ${publishImageHint(path)}` + : '' + console.warn(`[Image] ${reasons[reason]}${publication}`) + } + return new Response(null, { headers: { 'Cache-Control': 'private, no-store' }, status: 404 }) + } + return async function imageRoute(request: Request): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response(null, { headers: { Allow: 'GET, HEAD', 'Cache-Control': 'private, no-store' }, @@ -518,31 +957,104 @@ function createStorageRoute( }) } const url = new URL(request.url) - if (!matchesStorageRoute(url.pathname, delivery)) return notFound() - const transform = parseStorageRouteTransform(url, context, key) - if (transform === undefined) return notFound() + if (!matchesStorageRoute(url.pathname, delivery)) return notFound('route') + const transform = parseStorageRouteTransform( + url, + context, + key, + source.customTemplate === undefined + ? undefined + : { workspace: source.workspace, template: source.customTemplate }, + ) + if (transform === undefined) return notFound('capability') + if (isVersionedStorageTemplate(template) && transform.asset_id === 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 location = sign({ - expiresAt: getStorageExpiresAt(Date.now(), policy), - input: transform.path, + const path = transform.path + const isPublic = delivery.public?.some((prefix) => path.startsWith(prefix)) === true + const signRequest = { + input: transform.asset_id ?? 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, }, + } + // Old markup promises the private rendition's dimensions and quality. Keep signed delivery + // when the newly public Built-in cannot honor them, rather than break or silently resize it. + if ( + isPublic && + (!isVersionedStorageTemplate(source.publicTemplate) || transform.asset_id !== undefined) && + transform.width <= publicLimits.maxDimension && + transform.height <= publicLimits.maxDimension && + transform.quality <= publicLimits.maxQuality + ) { + const versioned = isVersionedStorageTemplate(source.publicTemplate) + const location = buildPublicUrl({ + ...signRequest, + input: versioned ? signRequest.input : path, + urlParams: { + ...signRequest.urlParams, + ...(versioned && transform.version_id !== undefined ? { v: transform.version_id } : {}), + }, + }) + diagnose?.( + path, + location, + policy.public.find((prefix) => path.startsWith(prefix)), + ) + return new Response(null, { + status: 307, + headers: { + Location: location, + // Bound caching of this policy transition; newly rendered public images go direct. + 'Cache-Control': 'public, max-age=0, s-maxage=60', + 'Referrer-Policy': 'no-referrer', + }, + }) + } + // Only unsigned delivery is checked against publication at the CDN. Every signed fallback + // still needs application authorization, including when local public policy is stale. + if ( + (await delivery.authorize({ + path, + request, + workspace: source.workspace, + template, + ...(transform.asset_id === undefined + ? {} + : { asset_id: transform.asset_id, version_id: transform.version_id }), + })) !== true + ) + return notFound('authorization', path) + 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, + urlParams: { + ...signRequest.urlParams, + ...(transform.version_id === undefined ? {} : { v: transform.version_id }), + }, }) + 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 +1063,283 @@ 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: ImageConfiguration, + 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): string => + getSmartCdnUrl({ + workspace: getWorkspace(), + baseUrl, + template: publicTemplate, + input: request.input, + urlParams: previewUrlParams(publicTemplate, { + ...urlParams, + ...request.urlParams, + }), + }) + 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, + image: TransloaditImageSource, + ): string => { + const capability = getCapability() + return getStorageRouteUrl( + capability.context, + capability.delivery, + capability.key, + request, + image, + customTemplate === undefined + ? undefined + : { workspace: getWorkspace(), template: customTemplate }, + ) + } - async function DirectStorageImage({ - props, - }: TransloaditStorageImageRequestProps): Promise { - await connection() + function createModel( + props: ResolvedStorageImageProps, + expiresAt: Expiry, + resolveUrl: ResolveTransloaditImage, + 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( + 'Image (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 Image(props: TransloaditImageProps): ReactNode { + const layout = resolveImageLayout(props, storagePolicy.images) + const publicPrefix = storagePolicy.public.find((prefix) => + layout.source.path.startsWith(prefix), + ) + const selectedTemplate = publicPrefix === undefined ? storageTemplate : publicTemplate + if (isVersionedStorageTemplate(selectedTemplate)) { + getStorageImageReference(layout.source, getWorkspace()) + } + assertAllowedStoragePath(layout.source.path, storagePolicy) + const storageProps = snapshotStorageImageProps(props, layout) + if (publicPrefix !== undefined) { + const model = createModel(storageProps, undefined, buildPublicUrl, 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 = { Image } + if (redirectDelivery === 'direct') return integration + let route: TransloaditImageRoute | undefined return { ...integration, - storageRoute: createStorageRoute( - storageCapability.context, - storageCapability.delivery, - storageCapability.key, - storagePolicy, - sign, - storageTemplate, - ), + async imageRoute(request) { + if (route === undefined) { + const capability = getCapability() + route = createStorageRoute( + capability.context, + capability.delivery, + capability.key, + storagePolicy, + sign, + storageTemplate, + diagnose, + buildPublicUrl, + { workspace: getWorkspace(), customTemplate, publicTemplate }, + ) + } + return await route(request) + }, } } + +/** Reads rendering credentials once on first render/request, not while importing the factory. */ +export function createImages( + configuration: AuthorizedImagesConfiguration, +): TransloaditRedirectImageIntegration +export function createImages( + configuration: ImageConfiguration, +): TransloaditImageIntegration +export function createImages( + input: ImageConfiguration, +): 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 = explicit.workspace ?? process.env.TRANSLOADIT_WORKSPACE + validateRequiredConfiguration(value, 'TRANSLOADIT_WORKSPACE') + if (!isImageSourceSelector(value)) + throw new TypeError('workspace must be 1–256 characters without surrounding whitespace') + 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 && + process.env.TRANSLOADIT_WORKSPACE !== undefined && + getWorkspace() !== process.env.TRANSLOADIT_WORKSPACE + ) + throw new TypeError( + 'Signing credentials from the environment belong to a different workspace. Provide explicit authKey and authSecret for the selected workspace.', + ) + 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/src/next/source.ts b/packages/img/src/next/source.ts new file mode 100644 index 00000000..ede8d62d --- /dev/null +++ b/packages/img/src/next/source.ts @@ -0,0 +1,6 @@ +/** Bound caller-selected URL identifiers equally during rendering and route dispatch. */ +export function isImageSourceSelector(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value + ) +} diff --git a/packages/img/test/conventional.test.tsx b/packages/img/test/conventional.test.tsx new file mode 100644 index 00000000..a942a35c --- /dev/null +++ b/packages/img/test/conventional.test.tsx @@ -0,0 +1,260 @@ +import type { StorageProjectCatalog } from '../src/next/catalog.ts' +import type { AuthorizeTransloaditImage } 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 storageReference = vi.hoisted(() => ({ + workspace: 'catalog-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) + +const project = vi.hoisted<{ + catalog: StorageProjectCatalog + authorize: AuthorizeTransloaditImage | undefined + authorizePath?: string +}>(() => ({ + catalog: { + workspace: 'catalog-app', + public: ['website/'], + images: { + 'website/hero.jpg': { + ...storageReference, + path: 'website/hero.jpg', + width: 1200, + height: 800, + }, + 'uploads/avatar.png': { + ...storageReference, + path: 'uploads/avatar.png', + width: 96, + height: 96, + }, + }, + delivery: undefined, + }, + authorize: undefined, +})) +const window = new Window() +vi.mock('server-only', () => ({})) +vi.mock('@transloadit/viewer/next/catalog', () => ({ + get default() { + return project.catalog + }, +})) +vi.mock('@transloadit/viewer/next/authorize', () => ({ + get authorize() { + return project.authorize + }, +})) +vi.mock('@transloadit/viewer/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 Image renders a catalog path with intrinsic dimensions and no secrets', async () => { + const { Image } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup( + Canal houses, + ) + 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 { Image } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup(Hero) + expect(html).toContain('http://127.0.0.1:32189/file/catalog-app/') + expect(html).toContain('cdn=required') +}) + +test('a preserved incomplete legacy receipt does not break another catalog image', async () => { + const original = project.catalog + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + project.catalog = { + ...original, + images: { + ...original.images, + 'legacy/incomplete.jpg': { path: 'legacy/incomplete.jpg', width: 0, height: 0 }, + }, + } + try { + const { Image } = await import('../src/next/react-server.tsx') + expect(renderToStaticMarkup(Hero)).toContain( + 'builtin%2Fpublic-preview', + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('legacy/incomplete.jpg')) + } finally { + project.catalog = original + } +}) + +test.each([ + 'catalog', + 'delivery', +])('extra %s metadata cannot configure the signing factory', async (location) => { + const original = project.catalog + const metadata = { + template: 'custom-template', + lifetime: -1, + authKey: 'metadata-key', + authSecret: 'metadata-secret', + allowWorkspaceRoot: true, + } + project.catalog = + location === 'catalog' + ? { ...original, ...metadata } + : { ...original, delivery: { ...original.delivery, ...metadata } } + try { + const { Image } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup(Hero) + expect(html).toContain('builtin%2Fpublic-preview%400.0.2') + expect(html).not.toMatch(/custom-template|metadata-key|metadata-secret|sig=/) + } finally { + project.catalog = original + } +}) + +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 { Image } = await import('../src/next/react-server.tsx') + const renderPrivate = () => + renderToStaticMarkup(Avatar) + 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(Hero) + 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 { Image } = await import('../src/next/react-server.tsx') + const { GET, HEAD } = await import('../src/next/route.ts') + const html = renderToStaticMarkup(Avatar) + 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( + storageReference.asset_id, + ) + expect(html).not.toMatch(/app-key|app-secret|auth_key/) +}) + +test('a late authorizer never silently leaves custom templates in direct development delivery', async () => { + vi.stubEnv('NODE_ENV', 'development') + const directory = await mkdtemp(join(tmpdir(), 'viewer-late-authorizer-')) + project.authorizePath = join(directory, 'transloadit.authorize.ts') + try { + const { Image } = await import('../src/next/react-server.tsx') + await writeFile(project.authorizePath, 'export const authorize = () => false\n') + expect(() => + renderToStaticMarkup( + Chair, + ), + ).toThrow(/authorizer|transloadit\.authorize\.ts.*Restart next dev/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +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( + '[Image] Catalog public prefixes changed. These paths now require the private image route and authorization: "website/hero.jpg".', + ) + const { Image: CatalogImage } = 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/image-selection.test.tsx b/packages/img/test/image-selection.test.tsx new file mode 100644 index 00000000..3ab6263e --- /dev/null +++ b/packages/img/test/image-selection.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' + +import type { StorageProjectCatalog } from '../src/next/catalog.ts' +import type { AuthorizeTransloaditImage } from '../src/next/server.tsx' + +import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const storageReference = vi.hoisted(() => ({ + workspace: 'catalog-shop', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) + +const project = vi.hoisted<{ + catalog: StorageProjectCatalog | undefined + authorize: AuthorizeTransloaditImage | undefined + workspace: string | undefined +}>(() => ({ catalog: undefined, authorize: undefined, workspace: undefined })) + +vi.mock('server-only', () => ({})) +vi.mock('next/server.js', () => ({ connection: async () => undefined })) +vi.mock('@transloadit/viewer/next/catalog', () => ({ + get default() { + return project.catalog + }, +})) +vi.mock('@transloadit/viewer/next/authorize', () => ({ + get authorize() { + return project.authorize + }, +})) +vi.mock('@transloadit/viewer/next/options', () => ({ + default: { + get workspace() { + return project.workspace + }, + }, +})) + +beforeEach(() => { + vi.resetModules() + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('TRANSLOADIT_WORKSPACE', undefined) + vi.stubEnv('TRANSLOADIT_KEY', 'test-key') + vi.stubEnv('TRANSLOADIT_SECRET', 'test-secret') + vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', undefined) + vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', undefined) + project.workspace = undefined + project.authorize = undefined + project.catalog = { + workspace: 'catalog-shop', + public: ['website/'], + images: { + 'website/hero.jpg': { + ...storageReference, + path: 'website/hero.jpg', + width: 1200, + height: 800, + }, + }, + } +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +async function markup(node: ReactNode): Promise { + const errors: unknown[] = [] + const stream = await renderToReadableStream(node, { + onError(error) { + errors.push(error) + }, + }) + await stream.allReady + const html = await new Response(stream).text() + if (errors.length > 0) throw errors[0] + return html +} + +function source(html: string): string { + const document = new DOMParser().parseFromString(html, 'text/html') + const src = document.querySelector('img:not([aria-hidden])')?.getAttribute('src') + if (!src) throw new Error('Expected the resolved native image') + expect(document.querySelector('img')?.hasAttribute('workspace')).toBe(false) + expect(document.querySelector('img')?.hasAttribute('storage')).toBe(false) + expect(document.querySelector('img')?.hasAttribute('template')).toBe(false) + return src +} + +test('Image storage takes its workspace and dimensions from the catalog', async () => { + const { Image } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup( + House, + ) + const parsed = parseSmartCdnUrl(source(html)) + expect(parsed.workspace).toBe('catalog-shop') + expect(parsed.template).toBe('builtin/public-preview@0.0.2') + expect(parsed.auth).toBeUndefined() + expect(html).toContain('height="640"') +}) + +test('Storage without a catalog names the upload command and dev restart', async () => { + project.catalog = undefined + const { Image } = await import('../src/next/react-server.tsx') + expect(() => renderToStaticMarkup(House)).toThrow( + /transloadit\.images\.json.*transloadit storage store.*[Rr]estart next dev/, + ) +}) + +test('Image rejects an explicit workspace that conflicts with its Storage catalog', async () => { + const { Image } = await import('../src/next/react-server.tsx') + expect(() => + renderToStaticMarkup( + House, + ), + ).toThrow(/workspace.*catalog-shop.*other-shop/i) +}) + +test('a project workspace default cannot reinterpret a Storage catalog', async () => { + project.workspace = 'other-shop' + const { Image } = await import('../src/next/react-server.tsx') + expect(() => renderToStaticMarkup(House)).toThrow( + /workspace.*catalog-shop.*other-shop/i, + ) +}) + +test('Storage still treats URL-like characters as literal object-key text', async () => { + const path = 'website/%2e%2e/photo?#.jpg' + project.catalog = { + workspace: 'catalog-shop', + public: ['website/'], + images: { [path]: { ...storageReference, path, width: 1200, height: 800 } }, + } + const { Image } = await import('../src/next/react-server.tsx') + const html = renderToStaticMarkup(Literal filename) + expect(parseSmartCdnUrl(source(html)).input).toBe(storageReference.asset_id) +}) + +test('a template image does not inherit the Storage catalog or its public prefixes', async () => { + const { Image } = await import('../src/next/react-server.tsx') + const html = await markup( + Product, + ) + const parsed = parseSmartCdnUrl(source(html)) + expect(parsed.workspace).toBe('catalog-shop') + expect(parsed.template).toBe('product-images') + expect(parsed.auth).toBeDefined() + expect(html).toContain('width="300"') + expect(html).not.toContain('test-secret') +}) + +test('a template image works without a Storage catalog or upload, using trusted CMS metadata', async () => { + project.catalog = undefined + const { Image } = await import('../src/next/react-server.tsx') + const html = await markup( + Chair, + ) + const parsed = parseSmartCdnUrl(source(html)) + expect(parsed.workspace).toBe('cms-shop') + expect(parsed.template).toBe('product-images') + expect(html).toContain('height="320"') +}) + +test('template workspace conflicts cannot silently borrow the default workspace credentials', async () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'default-shop') + const { Image } = await import('../src/next/react-server.tsx') + await expect( + markup( + Chair, + ), + ).rejects.toThrow(/credentials.*workspace/i) +}) + +test('runtime callers cannot choose storage and a template together', async () => { + const { Image } = await import('../src/next/react-server.tsx') + expect(() => + renderToStaticMarkup( + // @ts-expect-error JavaScript callers must receive the same selector rejection as TypeScript. + House, + ), + ).toThrow(/storage.*template/i) +}) + +test('a catalog-free template route denies requests without a source selector', async () => { + project.catalog = undefined + project.authorize = vi.fn(() => true) + const { GET } = await import('../src/next/route.ts') + const response = await GET(new Request('https://app.example/api/storage-images?cap=invalid')) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(project.authorize).not.toHaveBeenCalled() +}) + +test('custom template names must fit the redirect selector limit before rendering', async () => { + project.authorize = vi.fn(() => true) + const { Image } = await import('../src/next/react-server.tsx') + expect(() => + renderToStaticMarkup( + Photo, + ), + ).toThrow(/template.*256/i) +}) + +test.each([ + 'chairs/%2e%2e/private.jpg', + 'chairs/%252e%252e/private.jpg', + 'chairs/photo.jpg?download=private.jpg', + 'chairs/photo.jpg#private.jpg', +])('custom templates reject URL-interpreted input %s before signing', async (path) => { + project.authorize = vi.fn(() => true) + const { Image } = await import('../src/next/react-server.tsx') + expect(() => + renderToStaticMarkup( + Chair, + ), + ).toThrow(/Template inputs.*URL/i) + expect(project.authorize).not.toHaveBeenCalled() +}) + +test('a catalog-free template capability is bound to its explicit workspace cryptographically', async () => { + project.catalog = undefined + project.authorize = vi.fn(() => true) + const { Image } = await import('../src/next/react-server.tsx') + const { GET } = await import('../src/next/route.ts') + const html = renderToStaticMarkup( + Chair, + ) + const url = new URL(source(html), 'https://app.example') + expect((await GET(new Request(url))).status).toBe(307) + vi.mocked(project.authorize).mockClear() + url.searchParams.set('workspace', 'other-shop') + expect((await GET(new Request(url))).status).toBe(404) + expect(project.authorize).not.toHaveBeenCalled() +}) + +test('custom template redirects bind their workspace and template before per-object authorization', async () => { + const authorize = vi.fn( + ({ workspace, template, path, request }) => + workspace === 'catalog-shop' && + template === 'product-images' && + path === 'website/hero.jpg' && + request.headers.get('authorization') === 'Bearer allowed', + ) + project.authorize = authorize + const { Image } = await import('../src/next/react-server.tsx') + const { GET, HEAD } = await import('../src/next/route.ts') + const html = renderToStaticMarkup( + Product, + ) + const url = new URL(source(html), 'https://app.example') + expect(url.searchParams.get('template')).toBe('product-images') + expect(url.searchParams.get('workspace')).toBe('catalog-shop') + expect((await GET(new Request(url))).status).toBe(404) + const response = await HEAD(new Request(url, { headers: { authorization: 'Bearer allowed' } })) + expect(authorize).toHaveBeenCalledTimes(2) + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + workspace: 'catalog-shop', + template: 'product-images', + path: 'website/hero.jpg', + }), + ) + expect(response.status).toBe(307) + const destination = parseSmartCdnUrl(response.headers.get('location') ?? '') + expect(destination.workspace).toBe('catalog-shop') + expect(destination.template).toBe('product-images') + expect(destination.auth).toBeDefined() + expect(await response.text()).toBe('') + authorize.mockClear() + const wrongTemplate = new URL(url) + wrongTemplate.searchParams.set('template', 'other-images') + expect((await GET(new Request(wrongTemplate))).status).toBe(404) + const wrongWorkspace = new URL(url) + wrongWorkspace.searchParams.set('workspace', 'other-shop') + expect((await GET(new Request(wrongWorkspace))).status).toBe(404) + expect(authorize).not.toHaveBeenCalled() +}) diff --git a/packages/img/test/model.test.ts b/packages/img/test/model.test.ts index 292769eb..9304550b 100644 --- a/packages/img/test/model.test.ts +++ b/packages/img/test/model.test.ts @@ -1,6 +1,12 @@ import type { SmartCdnImageSignRequest, TransloaditImageModelOptions } from '../src/index.ts' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' + +const storageReference = vi.hoisted(() => ({ + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) import { createTransloaditImageModel } from '../src/index.ts' @@ -21,6 +27,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: { ...storageReference, 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: { ...storageReference, 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: { ...storageReference, 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.3'), + ) + 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: { ...storageReference, 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: { ...storageReference, 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: { ...storageReference, 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: { ...storageReference, 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: { ...storageReference, 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 = { + ...storageReference, + path: 'documents/report.pdf', + width: 400, + height: 300, + 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: { ...storageReference, path: src.path, width: 400, height: 300 } }, + fromString.sign, + ), + ) + expect(fromReceipt.requests).toEqual(fromString.requests) + expect(fromReceipt.requests[0]?.input).toBe(storageReference.asset_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( @@ -28,9 +208,9 @@ describe('createTransloaditImageModel', () => { expiresAt, fallbackQuality: 68, formats: { webp: 61 }, - height: 300, - src: 'documents/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, + widths: [400, 200], }, sign, @@ -52,21 +232,45 @@ describe('createTransloaditImageModel', () => { expect(requests).toEqual([ { expiresAt, - input: 'documents/report.pdf', - template: 'builtin/storage-preview@0.0.1', - urlParams: { f: 'webp', h: 150, q: 61, r: 'pad', w: 200 }, + input: storageReference.asset_id, + template: 'builtin/storage-preview@0.0.3', + urlParams: { + v: storageReference.version_id, + 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 }, + input: storageReference.asset_id, + template: 'builtin/storage-preview@0.0.3', + urlParams: { + v: storageReference.version_id, + 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 }, + input: storageReference.asset_id, + template: 'builtin/storage-preview@0.0.3', + urlParams: { + v: storageReference.version_id, + bg: '#ffffff', + f: 'jpg', + h: 300, + q: 68, + r: 'pad', + w: 400, + }, }, ]) }) @@ -96,9 +300,8 @@ describe('createTransloaditImageModel', () => { { expiresAt, formats: { webp: 75 }, - height: 300, - src: 'documents/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, }, sign, ) @@ -113,16 +316,24 @@ describe('createTransloaditImageModel', () => { { expiresAt, formats: { webp: 61 }, - height: 300, - src: 'documents/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, + widths: [200, 800], }, sign, ) 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({ + v: storageReference.version_id, + bg: '#ffffff', + f: 'jpg', + h: 300, + q: 75, + r: 'pad', + w: 400, + }) }) test('rejects an invalid fallback quality before signing any candidate', () => { @@ -133,9 +344,9 @@ describe('createTransloaditImageModel', () => { { expiresAt, fallbackQuality: 0, - height: 300, - src: 'documents/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, + widths: [200, 400], }, sign, @@ -150,9 +361,9 @@ describe('createTransloaditImageModel', () => { { expiresAt, formats: { webp: 70, avif: 40 }, - height: 1200, - src: 'portraits/report.pdf', - width: 400, + + src: { ...storageReference, path: 'portraits/report.pdf', width: 400, height: 1200 }, + widths: [8000], }, sign, @@ -163,7 +374,15 @@ 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({ + v: storageReference.version_id, + bg: '#ffffff', + f: 'jpg', + h: 1200, + q: 75, + r: 'pad', + w: 400, + }) }) test.each([ @@ -229,19 +448,22 @@ describe('createTransloaditImageModel', () => { let srcReads = 0 const options = { expiresAt, - height: 300, get src() { srcReads += 1 - return srcReads === 1 ? 'documents/report.pdf' : 'private/secret.pdf' + return { + ...storageReference, + path: srcReads === 1 ? 'documents/report.pdf' : 'private/secret.pdf', + width: 400, + height: 300, + } }, - width: 400, widths: [400], } satisfies TransloaditImageModelOptions createTransloaditImageModel(options, sign) expect(srcReads).toBe(1) - expect(requests.every(({ input }) => input === 'documents/report.pdf')).toBe(true) + expect(requests.every(({ input }) => input === storageReference.asset_id)).toBe(true) }) test('treats percent escapes as literal catalog key bytes', () => { @@ -250,15 +472,15 @@ describe('createTransloaditImageModel', () => { createTransloaditImageModel( { expiresAt, - height: 300, - src: 'documents/%2e%2e/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/%2e%2e/report.pdf', width: 400, height: 300 }, + widths: [400], }, sign, ) - expect(requests.every(({ input }) => input === 'documents/%2e%2e/report.pdf')).toBe(true) + expect(requests.every(({ input }) => input === storageReference.asset_id)).toBe(true) }) test('rejects a seconds-based expiry before signing', () => { @@ -268,9 +490,9 @@ describe('createTransloaditImageModel', () => { createTransloaditImageModel( { expiresAt: 1_893_456_000, - height: 300, - src: 'documents/report.pdf', - width: 400, + + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, + widths: [400], }, sign, diff --git a/packages/img/test/next-config.test.ts b/packages/img/test/next-config.test.ts new file mode 100644 index 00000000..416c0133 --- /dev/null +++ b/packages/img/test/next-config.test.ts @@ -0,0 +1,198 @@ +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<{ canReadAsset(assetId: string): boolean } | null>\n', + ) + await writeFile( + join(root, 'lib/images.ts'), + "import type { TransloaditImageSource } from '@transloadit/viewer'\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/viewer/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/viewer/next/catalog': './assets/images.json', + '@transloadit/viewer/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('an explicitly selected missing catalog names the upload command, not image init', async () => { + await rm(join(root, 'transloadit.images.json')) + expect(() => + withTransloaditImages({}, { root, catalog: 'transloadit.images.json' })(PHASE_PRODUCTION_BUILD), + ).toThrow(/storage store/) +}) + +test('custom-template projects need no Storage catalog and can configure a default workspace', async () => { + await rm(join(root, 'transloadit.images.json')) + const config = withTransloaditImages( + {}, + { root, workspace: 'custom-shop' }, + )(PHASE_PRODUCTION_BUILD) + expect(config.turbopack?.resolveAlias).not.toHaveProperty('@transloadit/viewer/next/catalog') + const options = JSON.parse( + await readFile(join(root, 'node_modules/.cache/transloadit-images/options.json'), 'utf8'), + ) + expect(options.workspace).toBe('custom-shop') +}) + +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/viewer/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..c8769d51 100644 --- a/packages/img/test/next-server.test.tsx +++ b/packages/img/test/next-server.test.tsx @@ -2,95 +2,1674 @@ import type { ReactNode } from 'react' +import type { ImageConfiguration } 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' -const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefined) })) +const storageReference = vi.hoisted(() => ({ + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) + +const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefined) })) + +vi.mock('next/server.js', () => ({ connection })) +vi.mock('server-only', () => ({})) + +import { createImageDiagnostics } from '../src/next/diagnostics.ts' +import { createImages } from '../src/next/server.tsx' + +const authSecret = 'never-render-this-secret' +const baseImages = { + 'documents/report.pdf': { + ...storageReference, + path: 'documents/report.pdf', + width: 800, + height: 600, + }, + 'documents/hero.jpg': { + ...storageReference, + path: 'documents/hero.jpg', + width: 1200, + height: 800, + }, +} +const baseConfiguration = { + images: baseImages, + delivery: 'direct', + authKey: 'auth-key', + authSecret, + baseUrl: 'https://cdn.example/file/{workspace}', + allowedPathPrefixes: ['documents/'], + workspace: 'my-app', +} satisfies ImageConfiguration + +async function renderAsync(node: ReactNode): Promise { + const stream = await renderToReadableStream(node) + await stream.allReady + return new Response(stream).text() +} + +function parseMarkup(markup: string): Document { + return new DOMParser().parseFromString(markup, 'text/html') +} + +function getFirstCandidate(document: Document): string { + const sourceSet = document.querySelector('source')?.getAttribute('srcset') + if (sourceSet === undefined || sourceSet === null) throw new Error('Expected an image source set') + const separator = sourceSet.indexOf(' ') + if (separator === -1) throw new Error('Expected a width descriptor') + return sourceSet.slice(0, separator) +} + +function getStorageRouteCandidate(): { + authorize: ReturnType + imageRoute: (request: Request) => Promise + url: URL +} { + const authorize = vi.fn( + ({ path, request }: { path: string; request: Request }): boolean => + path === 'documents/report.pdf' && request.headers.get('authorization') === 'Bearer allowed', + ) + const { Image, imageRoute } = createImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + authorize, + route: '/api/private-images', + }) + const markup = renderToStaticMarkup( + Report preview, + ) + return { + authorize, + imageRoute, + url: new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example'), + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime('2029-01-01T12:02:00.000Z') + connection.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('development delivery diagnostics', () => { + test('a successful custom Template response must still honor the requested format', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(null, { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }), + ) + const { Image } = createImages({ ...baseConfiguration, template: 'website-images' }) + await renderAsync( + Photo, + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/image\/png.*image\/webp.*Template/), + ) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch( + /auth-key|never-render-this-secret|sig=|exp=/, + ) + }) + + 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 { Image } = createImages({ + ...baseConfiguration, + baseUrl: 'https://cdn.example:8443/file/{workspace}', + template: 'website-images', + urlParams: { token: 'never-log-query-token' }, + }) + const markup = await renderAsync( + Hero, + ) + 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 { Image } = createImages(baseConfiguration) + await renderAsync( + Hero, + ) + expect(fetch).toHaveBeenCalledOnce() + expect(console.warn).not.toHaveBeenCalled() + }) + test('explains denied route boundaries with static, deduplicated reasons only', async () => { + const { imageRoute, url } = getStorageRouteCandidate() + const wrongRoute = new URL(url) + wrongRoute.pathname = '/wrong-route' + expect((await imageRoute(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 imageRoute(new Request(malformed))).status).toBe(404) + expect((await imageRoute(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 { Image } = createImages(baseConfiguration) + const props = { + alt: 'Hero', + src: { ...storageReference, path: 'documents/hero.jpg', width: 400, height: 300 }, + } + await renderAsync() + await renderAsync() + expect(console.info).toHaveBeenCalledExactlyOnceWith( + 'Image (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 { Image, imageRoute } = createImages({ + ...baseConfiguration, + delivery: undefined, + public: [], + authorize: () => false, + }) + const markup = renderToStaticMarkup( + Hero, + ) + const url = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') + const denied = await imageRoute(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 imageRoute(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 { Image } = createImages({ ...baseConfiguration, authorize: () => false }) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + try { + await act(() => + root.render( +

+ Inline photoImage 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 { Image } = createImages({ + ...baseConfiguration, + lifetime: 5000, + rotationIntervalMs: 1000, + }) + const rendered = renderAsync( + Preview, + ) + 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 { imageRoute, url } = getStorageRouteCandidate() + const response = imageRoute(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/avif' } })), + ) + 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 { Image } = createImages(baseConfiguration) + const props = { + alt: 'Preview', + src: { ...storageReference, 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 = createImages({ ...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 { Image } = createImages(baseConfiguration) + await renderAsync( + Preview, + ) + 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.*source 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 { Image } = createImages(baseConfiguration) + const props = { + alt: 'Preview', + src: { ...storageReference, 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 { Image } = createImages(baseConfiguration) + const markup = await renderAsync( + Preview, + ) + 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 { Image, imageRoute } = createImages({ + ...baseConfiguration, + route: '/images', + authorize, + }) + expect(() => + Image({ + alt: 'Denied', + src: { ...storageReference, path: 'private/hero.jpg', width: 400, height: 300 }, + }), + ).toThrow(/allowed prefixes/) + const document = parseMarkup( + renderToStaticMarkup( + Preview, + ), + ) + const request = new Request(new URL(getFirstCandidate(document), 'https://app.example')) + expect((await imageRoute(request)).status).toBe(404) + expect(fetch).not.toHaveBeenCalled() + authorize.mockReturnValue(true) + expect((await imageRoute(request)).status).toBe(307) + expect(fetch).toHaveBeenCalledOnce() + }) +}) + +describe('createImages', () => { + test.each([ + 'direct', + 'redirect', + ])('binds transparent candidates and the configured JPEG background through %s delivery', async (delivery) => { + const integration = + delivery === 'direct' + ? createImages(baseConfiguration) + : createImages({ + ...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 = + 'imageRoute' in integration + ? ( + await integration.imageRoute(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(() => + createImages({ + ...baseConfiguration, + urlParams: { bg: '#00000000' }, + }), + ).toThrow(/image policy parameter: bg/) + }) + + test.each([ + 'string', + 'receipt', + 'catalog', + ] as const)('%s sources share a responsive constrained default', async (kind) => { + const receipt = { ...storageReference, path: 'documents/hero.jpg', width: 1200, height: 800 } + const { Image } = createImages({ + ...baseConfiguration, + ...(kind === 'catalog' ? { images: { [receipt.path]: receipt } } : {}), + }) + const document = parseMarkup( + await renderAsync( + Responsive hero, + ), + ) + const image = document.querySelector('img') + expect(image?.style.cssText).toBe( + 'display: block; max-width: 1200px; width: 100%; height: auto;', + ) + expect(image?.width).toBe(1200) + expect(image?.height).toBe(800) + expect(document.querySelector('source')?.sizes).toBe('auto, (min-width: 1200px) 1200px, 100vw') + }) + + test('an explicit none layout leaves string source presentation to the caller', async () => { + const { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Caller-sized hero, + ), + ) + expect(document.querySelector('img')?.style.cssText).toBe('width: 300px; height: auto;') + expect(document.querySelector('source')?.sizes).toBe('50vw') + }) + + test('layout defaults leave explicit sizes, widths and styles in control', async () => { + const { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Override, + ), + ) + 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 { Image } = createImages({ ...baseConfiguration, images: undefined }) + expect(() => + // @ts-expect-error JavaScript callers can pass a string where fixed layout requires a receipt. + Image({ + 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 { Image } = createImages(baseConfiguration) + expect(() => + Reflect.apply(Image, undefined, [ + { + alt: 'Invalid', + src: { ...storageReference, 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 { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Hero, + ), + ) + 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 { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Small original, + ), + ) + 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 { Image, imageRoute } = createImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + }) + const document = parseMarkup( + renderToStaticMarkup( + Avatar, + ), + ) + 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 imageRoute( + 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', + v: storageReference.version_id, + }) + }) + + test('fill cover signs the declared box ratio and retains explicit layout overrides', async () => { + const { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Portrait crop, + ), + ) + 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 Image component', () => { + const integration = createImages(baseConfiguration) + expect(integration.Image).toBeTypeOf('function') + expect(Object.keys(integration)).toEqual(['Image']) + }) + + 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 { Image } = createImages({ + baseUrl: baseConfiguration.baseUrl, + allowedPathPrefixes: baseConfiguration.allowedPathPrefixes, + delivery: 'direct', + }) + const { Image: ExplicitImage } = createImages(baseConfiguration) + const props = { + alt: 'Snapshot', + src: { ...storageReference, 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 { Image } = createImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + }) + expect(() => + Image({ + src: { ...storageReference, path: 'documents/test.png', width: 10, height: 10 }, + 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 { Image } = createImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + }) + expect(() => + Image({ + src: { ...storageReference, path: 'documents/test.png', width: 10, height: 10 }, + 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(() => createImages(baseConfiguration)).not.toThrow() + }) + + test('still requires explicit scope and retains deny-all without path prefixes', () => { + expect(() => Reflect.apply(createImages, undefined, [{}])).toThrow(/allowedPathPrefixes/) + const { Image } = createImages({ + allowedPathPrefixes: [], + delivery: 'direct', + }) + expect(() => + Image({ + alt: 'Denied', + src: { ...storageReference, 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 { Image, imageRoute } = createImages({ + ...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 imageRoute(request) + expect(response.status).toBe(307) + expect(response.headers.get('Cache-Control')).toBe(expected) + delivery.authorize.mockReturnValue(false) + const denied = await imageRoute(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(() => + createImages({ + ...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(createImages, 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 { Image, imageRoute } = createImages({ + baseUrl: baseConfiguration.baseUrl, + template: 'website/preview', + urlParams: { cdn: 'required' }, + allowedPathPrefixes: ['documents/'], + route: '/images', + basePath: '/app', + authorize, + }) + const document = parseMarkup( + renderToStaticMarkup( + Image({ + alt: 'Report', + src: { ...storageReference, 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 imageRoute(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('createImages', () => { + 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 { Image } = createImages({ + workspace: 'my-app', + public: ['documents/'], + allowedPathPrefixes: ['documents/'], + }) + const document = parseMarkup( + renderToStaticMarkup( + Original, + ), + ) + 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 { Image } = createImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + basePath: '/explicit', + }) + expect( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + Base path, + ), + ), + ), + ).toMatch(/^\/explicit\/images\?/) + } finally { + vi.unstubAllEnvs() + } + }) + + test('renders real per-breakpoint crops with a JPEG fallback for each art direction', () => { + const { Image } = createImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + }) + const document = parseMarkup( + renderToStaticMarkup( + Art-directed hero, + ), + ) + 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 { Image } = createImages({ + ...baseConfiguration, + authorize: () => true, + route: '/images', + }) + const document = parseMarkup( + renderToStaticMarkup( + Column, + ), + ) + expect(document.querySelector('source')?.getAttribute('sizes')).toBe(sizes) + }) + + test('layout none keeps intrinsic signing geometry while explicit dimensions describe presentation', async () => { + const { Image } = createImages(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Sized receipt, + ), + ) + 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 { imageRoute } = createImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + ...delivery, + }) + // Existing private markup remains usable when its directory is deliberately published. + const { Image } = createImages({ + ...baseConfiguration, + authorize, + route: '/images', + }) + delivery.public.push('documents/private/') + const candidate = (path: string): Request => + new Request( + new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + Preview, + ), + ), + ), + 'https://app.example', + ), + ) + const response = await imageRoute(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 imageRoute(candidate('documents/private/hero.jpg')) + expect(denied.status).toBe(404) + expect(denied.headers.get('cache-control')).toBe('private, no-store') + expect((await imageRoute(candidate('documents/publicity/hero.jpg'))).status).toBe(404) + expect(authorize).toHaveBeenCalledTimes(2) + vi.setSystemTime('2029-01-01T12:59:59.000Z') + expect( + (await imageRoute(candidate('documents/public/hero.jpg'))).headers.get('cache-control'), + ).toBe('public, max-age=0, s-maxage=60') + const refreshed = createImages({ + ...baseConfiguration, + authorize, + route: '/images', + public: ['documents/public/'], + images: { + 'documents/public/hero.jpg': { + ...storageReference, + path: 'documents/public/hero.jpg', + width: 400, + height: 300, + md5hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }, + }) + const current = await refreshed.imageRoute(candidate('documents/public/hero.jpg')) + expect(new URL(current.headers.get('location') ?? '').searchParams.get('v')).toBe( + storageReference.version_id, + ) + expect(current.headers.get('cache-control')).toBe('public, max-age=0, s-maxage=60') + }) + + test.each([ + { width: 6000, height: 6000, quality: 75 }, + { width: 1000, height: 6000, quality: 75 }, + { width: 400, height: 600, quality: 100 }, + ])('publishing retains authorized signed delivery for existing $width × $height / q=$quality capabilities beyond public limits', async ({ + width, + height, + quality, + }) => { + const authorize = vi.fn(() => true) + const configuration = { ...baseConfiguration, authorize, route: '/images' } + const { Image } = createImages(configuration) + const candidate = getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + Existing private rendition, + ), + ), + ) + const { imageRoute } = createImages({ ...configuration, public: ['documents/'] }) + const response = await imageRoute(new Request(new URL(candidate, 'https://app.example'))) + expect(response.status).toBe(307) + const location = response.headers.get('location') + if (location === null) throw new Error('Expected compatible delivery Location') + const parsed = parseSmartCdnUrl(location, { baseUrl: baseConfiguration.baseUrl }) + expect(parsed.template).toBe('builtin/storage-preview@0.0.3') + expect(parsed.auth).toBeDefined() + expect(parsed.urlParams).toMatchObject({ + w: String(width), + h: String(height), + }) + expect(parsed.urlParams.q ?? '75').toBe(String(quality)) + expect(response.headers.get('cache-control')).toBe('private, no-store') + // Local publication configuration can lag behind a server-side revocation. + authorize.mockReturnValue(false) + const denied = await imageRoute(new Request(new URL(candidate, 'https://app.example'))) + expect(denied.status).toBe(404) + expect(denied.headers.get('location')).toBeNull() + expect(denied.headers.get('cache-control')).toBe('private, no-store') + expect(authorize).toHaveBeenCalledTimes(2) + }) + + test('rejects public prefixes outside the signing policy', () => { + expect(() => + createImages({ + ...baseConfiguration, + allowedPathPrefixes: ['documents/'], + route: '/images', + authorize: () => true, + public: ['documents'], + }), + ).toThrow('public[0]') + expect(() => + createImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + public: ['other/'], + }), + ).toThrow(/public.*allowedPathPrefixes/) + }) -vi.mock('next/server.js', () => ({ connection })) -vi.mock('server-only', () => ({})) + 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 = createImages({ + ...baseConfiguration, + template: 'my-custom-preview', + ...storage, + }) + const url = new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + , + ), + ), + ), + 'https://app.example', + ) + const current = createImages({ + ...baseConfiguration, + ...storage, + template: 'my-custom-preview', + }) + const response = await current.imageRoute(new Request(url)) + expect(response.status).toBe(307) + expect(decodeURIComponent(response.headers.get('location') ?? '')).toContain( + 'my-custom-preview', + ) + authorize.mockReturnValue(false) + expect((await current.imageRoute(new Request(url))).status).toBe(404) + authorize.mockReturnValue(true) + const revoked = createImages({ + ...baseConfiguration, + ...storage, + template: 'another-custom-preview', + }) + expect((await revoked.imageRoute(new Request(url))).status).toBe(404) + const rotated = createImages({ + ...baseConfiguration, + ...storage, + authSecret: 'rotated', + template: 'my-custom-preview', + }) + expect((await rotated.imageRoute(new Request(url))).status).toBe(404) + vi.setSystemTime(Date.now() + 365 * 86_400_000) + expect((await current.imageRoute(new Request(url))).status).toBe(307) + const restarted = createImages({ + ...baseConfiguration, + ...storage, + template: 'my-custom-preview', + }) + expect((await restarted.imageRoute(new Request(url))).status).toBe(307) + }) -import { createTransloaditImage } from '../src/next/server.tsx' + 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 { Image, imageRoute } = createImages({ + allowedPathPrefixes: ['documents/'], + authorize: () => true, + lifetime: 600_000, + }) + const url = new URL( + getFirstCandidate( + parseMarkup( + renderToStaticMarkup( + Private, + ), + ), + ), + 'https://app.example', + ) + expect(url.pathname).toBe('/api/storage-images') + const response = await imageRoute(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() + } + }) -const authSecret = 'never-render-this-secret' -const baseConfiguration = { - authKey: 'auth-key', - authSecret, - baseUrl: 'https://cdn.example/file/{workspace}', - storage: { allowedPathPrefixes: ['documents/'] }, - workspace: 'my-app', -} + test('rejects a missing prefix policy at factory time for untyped callers', () => { + expect(() => + Reflect.apply(createImages, undefined, [ + { ...baseConfiguration, allowedPathPrefixes: undefined, images: undefined }, + ]), + ).toThrow('images, allowedPathPrefixes or allowWorkspaceRoot: true is required') + }) -async function renderAsync(node: ReactNode): Promise { - const stream = await renderToReadableStream(node) - await stream.allReady - return new Response(stream).text() -} + test('exports only the named Image component, not the unpublished Image alias', () => { + expect(Object.keys(createImages(baseConfiguration))).toEqual(['Image']) + }) -function parseMarkup(markup: string): Document { - return new DOMParser().parseFromString(markup, 'text/html') -} + test('shares CDN URLs throughout the default expiry bucket, then rotates at its boundary', async () => { + const { Image } = createImages(baseConfiguration) + const render = async (): Promise => + getFirstCandidate( + parseMarkup( + await renderAsync( + Hourly preview, + ), + ), + ) + 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) + }) -function getFirstCandidate(document: Document): string { - const sourceSet = document.querySelector('source')?.getAttribute('srcset') - if (sourceSet === undefined || sourceSet === null) throw new Error('Expected an image source set') - const separator = sourceSet.indexOf(' ') - if (separator === -1) throw new Error('Expected a width descriptor') - return sourceSet.slice(0, separator) -} + test('shares redirect targets throughout the default expiry bucket while still authorizing', async () => { + const { authorize, imageRoute, url } = getStorageRouteCandidate() + const request = new Request(url, { headers: { Authorization: 'Bearer allowed' } }) + const first = await imageRoute(request) + vi.setSystemTime('2029-01-01T12:29:59.999Z') + expect((await imageRoute(request)).headers.get('location')).toBe(first.headers.get('location')) + expect(authorize).toHaveBeenCalledTimes(2) + }) -function getStorageRouteCandidate(): { - authorize: ReturnType - storageRoute: (request: Request) => Promise - url: URL -} { - const authorize = vi.fn( - ({ path, request }: { path: string; request: Request }): boolean => - path === 'documents/report.pdf' && request.headers.get('authorization') === 'Bearer allowed', - ) - const { Image, storageRoute } = createTransloaditImage({ - ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize, route: '/api/private-images' }, - }, + test.each([ + 'direct', + 'redirect', + ])('renders a receipt exactly like its string equivalent with %s delivery', async (delivery) => { + const src = { + ...storageReference, + path: 'documents/report.pdf', + width: 400, + height: 300, + md5hash: 'd41d8cd98f00b204e9800998ecf8427e', + authSecret: 'secret-from-receipt', + id: 'not-an-attribute', + } + const { Image } = createImages({ + ...baseConfiguration, + images: { 'documents/report.pdf': src }, + ...(delivery === 'direct' ? {} : { route: '/images', authorize: () => true }), + }) + const received = parseMarkup(await renderAsync(Image({ alt: 'Report', src }))) + const expected = parseMarkup( + await renderAsync( + Image({ alt: 'Report', src: 'documents/report.pdf', 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('') }) - const markup = renderToStaticMarkup( - Report preview, - ) - return { - authorize, - storageRoute, - url: new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example'), - } -} -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime('2029-01-01T12:02:00.000Z') - connection.mockClear() -}) + test.each( + [ + null, + [], + {}, + { toString: () => 'documents/report.pdf' }, + { ...storageReference, path: 'documents/../secret.pdf', width: 400, height: 300 }, + { ...storageReference, path: 'private/report.pdf', width: 400, height: 300 }, + { ...storageReference, path: 'documents/report.pdf', width: '400', height: 300 }, + { ...storageReference, path: 'documents/report.pdf', width: 0, height: 300 }, + { ...storageReference, path: 'documents/report.pdf', width: 400, height: 1.5 }, + { + ...storageReference, + path: 'documents/report.pdf', + width: 400, + height: Number.POSITIVE_INFINITY, + }, + { + ...storageReference, + 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 { Image } = createImages(baseConfiguration) + expect(() => Reflect.apply(Image, undefined, [{ alt: 'Invalid', src }])).toThrow() + expect(connection).not.toHaveBeenCalled() + }) -afterEach(() => { - vi.useRealTimers() -}) + test('rejects invalid presentation dimensions from JavaScript callers', () => { + const { Image } = createImages(baseConfiguration) + expect(() => + Reflect.apply(Image, undefined, [ + { + alt: 'Ambiguous', + src: { ...storageReference, 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 { Image } = createImages(baseConfiguration) + const src = { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 } + connection.mockImplementationOnce(() => { + Object.assign(src, { ...storageReference, 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(storageReference.asset_id) + 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 { Image, imageRoute } = createImages({ + ...baseConfiguration, + route: '/images', + authorize: () => true, + }) + const source = { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 } + const props = { + alt: 'Stable redirect', + src: 'documents/report.pdf' as const, + 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, { + ...storageReference, + 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 imageRoute( + 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(storageReference.asset_id) + 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 { Image } = createImages(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 { Image } = createImages(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() + }) -describe('createTransloaditImage', () => { test('allows explicit widths while making sizes optional', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { Image } = createImages(baseConfiguration) const document = parseMarkup( await renderAsync( Explicit widths, @@ -98,14 +1677,72 @@ describe('createTransloaditImage', () => { ) const source = document.querySelector('source') - expect(source?.hasAttribute('sizes')).toBe(false) + expect(source?.getAttribute('sizes')).toBe('auto, (min-width: 800px) 800px, 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 { Image } = createImages({ + ...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 { Image } = createImages({ + ...baseConfiguration, + ...(delivery === 'direct' ? {} : { authorize: () => true, route: '/api/private-images' }), + }) + expect(() => + Reflect.apply(Image, undefined, [ + { + alt: { text: 'Report' }, + height: 600, + src: { ...storageReference, path: 'documents/report.pdf', width: 800, height: 600 }, + width: 800, + }, + ]), + ).toThrow('Image alt must be a string') + }) + test('rejects coercible Storage sources before signing', () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { Image } = createImages(baseConfiguration) const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg') expect(() => @@ -117,7 +1754,7 @@ describe('createTransloaditImage', () => { }) test('request-renders direct Storage previews with bounded stable signatures', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { Image } = createImages(baseConfiguration) const render = async (): Promise => { const markup = await renderAsync( { formats={{ webp: 61 }} height={300} sizes="400px" - src="documents/report.pdf" + src={{ ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }} width={400} widths={[200, 400]} />, @@ -139,21 +1776,21 @@ describe('createTransloaditImage', () => { 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.3/') 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,17 +1798,17 @@ describe('createTransloaditImage', () => { }) test('denies private paths by default and matches explicit directory boundaries', () => { - const { Image: denyAllImage } = createTransloaditImage({ + const { Image: denyAllImage } = createImages({ ...baseConfiguration, - storage: {}, + allowedPathPrefixes: [], }) - const { Image } = createTransloaditImage(baseConfiguration) + const { Image } = createImages(baseConfiguration) expect(() => denyAllImage({ alt: 'Denied', height: 300, - src: 'documents/report.pdf', + src: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, width: 400, }), ).toThrow('outside the configured allowed prefixes') @@ -179,7 +1816,7 @@ describe('createTransloaditImage', () => { Image({ alt: 'Boundary mismatch', height: 300, - src: 'documents-private/report.pdf', + src: { ...storageReference, path: 'documents-private/report.pdf', width: 400, height: 300 }, width: 400, }), ).toThrow('outside the configured allowed prefixes') @@ -187,12 +1824,14 @@ describe('createTransloaditImage', () => { }) test('snapshots direct Storage props before crossing the request boundary', async () => { - const { Image } = createTransloaditImage(baseConfiguration) + const { Image } = createImages(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,8 +1841,11 @@ describe('createTransloaditImage', () => { get height() { return height }, + get id() { + return id + }, get src() { - return path + return { ...storageReference, path, width: 800, height: 600 } }, get width() { return width @@ -216,9 +1858,10 @@ describe('createTransloaditImage', () => { workspace: baseConfiguration.workspace, }) - expect(candidate.input).toBe('documents/report.pdf') + expect(candidate.input).toBe(storageReference.asset_id) 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,28 +1879,29 @@ describe('createTransloaditImage', () => { }) test('prepends basePath while accepting Next.js stripped handler paths', async () => { - const { Image, storageRoute } = createTransloaditImage({ + const { Image, imageRoute } = createImages({ ...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, + Base path, ) const externalUrl = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') const internalUrl = new URL(externalUrl) internalUrl.pathname = '/api/private-images' - const internalResponse = await storageRoute(new Request(internalUrl)) - const externalResponse = await storageRoute(new Request(externalUrl)) + const internalResponse = await imageRoute(new Request(internalUrl)) + const externalResponse = await imageRoute(new Request(externalUrl)) const trailingSlashUrl = new URL(externalUrl) trailingSlashUrl.pathname = `${trailingSlashUrl.pathname}/` - const trailingSlashResponse = await storageRoute(new Request(trailingSlashUrl)) + const trailingSlashResponse = await imageRoute(new Request(trailingSlashUrl)) expect(externalUrl.pathname).toBe('/app/api/private-images') expect(internalResponse.status).toBe(307) @@ -272,25 +1916,29 @@ describe('createTransloaditImage', () => { return 'false' }, }) - const { Image, storageRoute } = createTransloaditImage({ + const { Image, imageRoute } = createImages({ ...baseConfiguration, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize: malformedAuthorize, route: '/api/private-images' }, - }, + allowedPathPrefixes: ['documents/'], + authorize: malformedAuthorize, + route: '/api/private-images', }) const markup = renderToStaticMarkup( - Strict ACL, + Strict ACL, ) const routeUrl = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') - expect(await storageRoute(new Request(routeUrl))).toMatchObject({ status: 404 }) + expect(await imageRoute(new Request(routeUrl))).toMatchObject({ status: 404 }) }) test('authorizes one exact route request and redirects without proxying image bytes', async () => { - const { authorize, storageRoute, url } = getStorageRouteCandidate() + const { authorize, imageRoute, url } = getStorageRouteCandidate() const request = new Request(url, { headers: { Authorization: 'Bearer allowed' } }) - const response = await storageRoute(request) + const response = await imageRoute(request) const location = response.headers.get('location') if (location === null) throw new Error('Expected a redirect location') const target = parseSmartCdnUrl(location, { @@ -303,34 +1951,52 @@ describe('createTransloaditImage', () => { expect(response.headers.get('cache-control')).toBe('private, no-store') 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.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(authorize).toHaveBeenCalledWith({ + asset_id: storageReference.asset_id, + version_id: storageReference.version_id, + path: 'documents/report.pdf', + request, + workspace: 'my-app', + template: 'builtin/storage-preview@0.0.3', + }) + expect(target.template).toBe('builtin/storage-preview@0.0.3') + expect(target.input).toBe(storageReference.asset_id) + expect(target.urlParams).toEqual({ + bg: '#00000000', + f: 'avif', + h: '240', + q: '45', + w: '320', + v: storageReference.version_id, + }) + 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 { Image, imageRoute } = createImages({ ...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( - Stable, + Stable, ) return new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') } const first = render() - const firstRedirect = await storageRoute(new Request(first)) + const firstRedirect = await imageRoute(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)) + const secondRedirect = await imageRoute(new Request(second)) + const cachedRedirect = await imageRoute(new Request(first)) expect(second.href).toBe(first.href) expect(secondRedirect.headers.get('location')).not.toBe(firstRedirect.headers.get('location')) @@ -339,6 +2005,54 @@ 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 { Image, imageRoute } = createImages({ + ...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 imageRoute(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 imageRoute(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 imageRoute(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,16 +2069,16 @@ describe('createTransloaditImage', () => { storageTemplate?: string workspace?: string } = {}) => - createTransloaditImage({ + createImages({ ...baseConfiguration, authSecret: candidateSecret, - storage: { - allowedPathPrefixes: ['documents/'], - delivery: { authorize, basePath, route }, - }, + allowedPathPrefixes: ['documents/'], + authorize, + basePath, + route, template: storageTemplate, workspace, - }).storageRoute + }).imageRoute const otherRouteUrl = new URL(url) otherRouteUrl.pathname = '/api/other-images' const basePathUrl = new URL(url) @@ -373,32 +2087,32 @@ describe('createTransloaditImage', () => { { label: 'secret', requestUrl: url, - storageRoute: createBoundRoute({ authSecret: 'another-secret' }), + imageRoute: createBoundRoute({ authSecret: 'another-secret' }), }, { label: 'workspace', requestUrl: url, - storageRoute: createBoundRoute({ workspace: 'another-app' }), + imageRoute: createBoundRoute({ workspace: 'another-app' }), }, { label: 'Template', requestUrl: url, - storageRoute: createBoundRoute({ storageTemplate: 'customer/storage-preview' }), + imageRoute: createBoundRoute({ storageTemplate: 'customer/storage-preview' }), }, { label: 'route', requestUrl: otherRouteUrl, - storageRoute: createBoundRoute({ route: '/api/other-images' }), + imageRoute: createBoundRoute({ route: '/api/other-images' }), }, { label: 'basePath', requestUrl: basePathUrl, - storageRoute: createBoundRoute({ basePath: '/app' }), + imageRoute: createBoundRoute({ basePath: '/app' }), }, ] - for (const { label, requestUrl, storageRoute } of attempts) { - const response = await storageRoute( + for (const { label, requestUrl, imageRoute } of attempts) { + const response = await imageRoute( new Request(requestUrl, { headers: { Authorization: 'Bearer allowed' } }), ) expect(response.status, label).toBe(404) @@ -460,9 +2174,9 @@ describe('createTransloaditImage', () => { ] for (const { label, mutate } of mutations) { - const { authorize, storageRoute, url } = getStorageRouteCandidate() + const { authorize, imageRoute, url } = getStorageRouteCandidate() mutate(url) - const response = await storageRoute( + const response = await imageRoute( new Request(url, { headers: { Authorization: 'Bearer allowed' } }), ) expect(response.status, label).toBe(404) @@ -473,12 +2187,12 @@ describe('createTransloaditImage', () => { }) test('conceals failed application authorization and disallows other methods', async () => { - const { authorize, storageRoute, url } = getStorageRouteCandidate() - const denied = await storageRoute(new Request(url)) - const post = await storageRoute( + const { authorize, imageRoute, url } = getStorageRouteCandidate() + const denied = await imageRoute(new Request(url)) + const post = await imageRoute( new Request(url, { headers: { Authorization: 'Bearer allowed' }, method: 'POST' }), ) - const head = await storageRoute( + const head = await imageRoute( new Request(url, { headers: { Authorization: 'Bearer allowed' }, method: 'HEAD' }), ) @@ -492,22 +2206,23 @@ describe('createTransloaditImage', () => { }) test('rejects direct-only suspense props in static redirect mode', () => { - const { Image } = createTransloaditImage({ + const { Image } = createImages({ ...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: { ...storageReference, path: 'documents/report.pdf', width: 400, height: 300 }, + suspenseFallback: 'Loading', + width: 400, + }, + ]), ).toThrow('suspenseFallback is only used by direct Storage delivery') }) @@ -522,7 +2237,7 @@ describe('createTransloaditImage', () => { 'w', ])('reserves image-policy parameter %s from global URL parameters', (parameter) => { expect(() => - createTransloaditImage({ + createImages({ ...baseConfiguration, urlParams: { [parameter]: 'caller-controlled' }, }), @@ -530,65 +2245,62 @@ describe('createTransloaditImage', () => { }) test('validates credentials, route configuration, and bounded expiry', () => { - expect(() => createTransloaditImage({ ...baseConfiguration, authKey: '' })).toThrow( + expect(() => createImages({ ...baseConfiguration, authKey: '' })).toThrow( 'authKey must be a non-empty string', ) + expect(() => createImages({ ...baseConfiguration, baseUrl: 'ftp://cdn.example/file' })).toThrow( + 'baseUrl must be an absolute HTTP(S) URL', + ) expect(() => - createTransloaditImage({ ...baseConfiguration, baseUrl: 'ftp://cdn.example/file' }), - ).toThrow('baseUrl must be an absolute HTTP(S) URL') - expect(() => - createTransloaditImage({ + createImages({ ...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({ + createImages({ ...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({ + createImages({ ...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(createImages, 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 { Image } = createImages({ ...baseConfiguration, template: 'my-storage-preview', }) const storageDocument = parseMarkup( await renderAsync( - Storage, + Storage, ), ) 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..3f367858 --- /dev/null +++ b/packages/img/test/public-storage.test.tsx @@ -0,0 +1,505 @@ +// @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 storageReference = vi.hoisted(() => ({ + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) + +const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefined) })) +vi.mock('next/server.js', () => ({ connection })) +vi.mock('server-only', () => ({})) + +import { createImages } from '../src/next/server.tsx' + +const hash = 'd41d8cd98f00b204e9800998ecf8427e' +const images = { + 'website/hero.jpg': { + ...storageReference, + path: 'website/hero.jpg', + width: 2400, + height: 1600, + md5hash: hash, + }, + 'private/avatar.png': { + ...storageReference, + 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 { Image } = createImages({ + images: { + 'website/large.jpg': { ...storageReference, path: 'website/large.jpg', ...dimensions }, + }, + public: ['website/'], + }) + const markup = renderToStaticMarkup(Large) + 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 { Image } = createImages({ + images: { + 'website/large.jpg': { + ...storageReference, + path: 'website/large.jpg', + width: 6000, + height: 6000, + }, + }, + public: ['website/'], + }) + const markup = renderToStaticMarkup( + Crop, + ) + 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 { Image } = createImages({ images, public: ['website/'] }) + expect(() => + renderToStaticMarkup(Hero), + ).toThrow(/quality.*85/i) + expect(() => + renderToStaticMarkup(Hero), + ).toThrow(/quality.*85/i) +}) + +test('Built-in URLs omit defaults but retain transparent format parameters and explicit dimensions', () => { + const { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Hero, + ) + const fallback = parseSmartCdnUrl(imageUrl(markup)) + expect(fallback.urlParams).toEqual({ w: '2400', h: '1600', v: storageReference.version_id }) + 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: storageReference.version_id, + }) +}) + +test('custom Templates keep every transform field because their defaults are not known', () => { + const { Image } = createImages({ + images, + public: ['website/'], + publicTemplate: 'customer-preview-template', + delivery: { urlParams: { cdn: 'required' } }, + }) + const url = imageUrl(renderToStaticMarkup(Hero)) + expect(parseSmartCdnUrl(url).urlParams).toEqual({ + w: '2400', + h: '1600', + f: 'jpg', + bg: '#ffffff', + r: 'pad', + q: '75', + cdn: 'required', + }) +}) + +test('nondefault crop, background and quality remain explicit on compact Built-in URLs', () => { + const { Image } = createImages({ + images, + public: ['website/'], + delivery: { urlParams: { cdn: 'required' } }, + }) + const url = imageUrl( + renderToStaticMarkup( + Hero, + ), + ) + expect(parseSmartCdnUrl(url).urlParams).toEqual({ + w: '200', + h: '200', + bg: '#224466', + r: 'fillcrop', + q: '80', + cdn: 'required', + v: storageReference.version_id, + }) +}) + +test('a private redirect signs the compact parameters and retains authentication and expiry', async () => { + const authorize = vi.fn(() => true) + const { Image, imageRoute } = createImages({ + images, + authorize, + authKey: 'signing-key', + authSecret: 'signing-secret', + }) + const path = imageUrl(renderToStaticMarkup(Private)) + const response = await imageRoute(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', v: storageReference.version_id }) + 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: storageReference.asset_id, + template: 'builtin/storage-preview@0.0.3', + urlParams: { w: 400, h: 300, v: storageReference.version_id }, + }), + ) + expect(authorize).toHaveBeenCalledOnce() + expect(await response.text()).toBe('') +}) + +test('public receipt images render permanent versioned URLs without any signing credentials', () => { + const { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Hero, + ) + const parsed = parseSmartCdnUrl(imageUrl(markup)) + expect(parsed.template).toBe('builtin/public-preview@0.0.2') + expect(parsed.auth).toBeUndefined() + expect(parsed.urlParams.v).toBe(storageReference.version_id) + 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 { Image } = createImages({ images: {}, public: ['website/'] }) + const markup = renderToStaticMarkup(Hero) + expect(parseSmartCdnUrl(imageUrl(markup)).auth).toBeUndefined() + expect(() => Image({ 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(() => createImages({ 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 { Image } = createImages({ images, public: ['website/'], lifetime: '2h' }) + return renderToStaticMarkup(Hero) + } + const first = render() + now.mockReturnValue(2_900_000_000_000) + expect(render()).toBe(first) +}) + +test('a new version gets a new public cache key; receipt identities are snapshotted', () => { + const catalog = structuredClone(images) + const { Image } = createImages({ images: catalog, public: ['website/'] }) + catalog['website/hero.jpg'].version_id = 'C'.repeat(21) + 'A' + const old = imageUrl(renderToStaticMarkup(Hero)) + const current = createImages({ images: catalog, public: ['website/'] }) + const next = imageUrl(renderToStaticMarkup()) + expect(new URL(old).searchParams.get('v')).toBe(storageReference.version_id) + expect(new URL(next).searchParams.get('v')).toBe('C'.repeat(21) + 'A') + expect(next).not.toBe(old) +}) + +test('a geometry-only Storage receipt requires recovery instead of inventing a version', () => { + const { Image } = createImages({ + images: { 'website/legacy.jpg': { path: 'website/legacy.jpg', width: 800, height: 600 } }, + public: ['website/'], + }) + expect(() => renderToStaticMarkup(Legacy)).toThrow( + /storage receipts sync/, + ) +}) + +test.each([ + `${'a'.repeat(512)}/`, + `${'é'.repeat(256)}/`, +])('rejects an oversized public directory %s', (prefix) => { + expect(() => createImages({ allowedPathPrefixes: [prefix], public: [prefix] })).toThrow(/512/) +}) + +test('public directories can contain exactly 512 UTF-8 bytes', () => { + const prefix = `${'é'.repeat(255)}a/` + expect(() => createImages({ 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 { Image } = createImages({ + images, + authKey: 'different-key', + authorize: () => true, + }) + expect(() => Image({ 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 { Image } = createImages({ images, authorize: () => true }) + expect(() => Image({ src: 'private/avatar.png', alt: 'Private' })).toThrow( + /TRANSLOADIT_SMART_CDN_SECRET/, + ) +}) + +test('a public-only selection cannot silently sign a private catalog member', () => { + const { Image } = createImages({ images, public: ['website/'] }) + expect(() => Image({ 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 { Image, imageRoute } = createImages({ + images, + public: ['website/'], + authorize, + }) + const publicUrl = imageUrl(renderToStaticMarkup(Hero)) + expect(parseSmartCdnUrl(publicUrl).auth).toBeUndefined() + const privateUrl = imageUrl(renderToStaticMarkup(Avatar)) + expect(privateUrl).toMatch(/^\/api\/storage-images\?cap=/) + const response = await imageRoute(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.3') + expect(new URL(target).searchParams.get('v')).toBe(storageReference.version_id) + 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 { Image, imageRoute } = createImages({ images, authorize: () => true }) + const src = imageUrl(renderToStaticMarkup(Avatar)) + const target = (await imageRoute(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 { Image } = createImages({ + images, + public: ['website/'], + template: 'builtin/storage-preview@0.0.3', + }) + const url = imageUrl(renderToStaticMarkup(Hero)) + expect(parseSmartCdnUrl(url).template).toBe('builtin/public-preview@0.0.2') + 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 { Image, imageRoute } = createImages({ + images, + public: ['website/'], + authorize: () => true, + template: 'private-preview', + publicTemplate: 'public-preview', + }) + const publicUrl = imageUrl(renderToStaticMarkup(Hero)) + expect(parseSmartCdnUrl(publicUrl).template).toBe('public-preview') + expect(parseSmartCdnUrl(publicUrl).auth).toBeUndefined() + const privateUrl = imageUrl( + renderToStaticMarkup(Private), + ) + const response = await imageRoute(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(() => + createImages({ 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 { Image } = createImages({ images, public: ['website/'] }) + expect(renderToStaticMarkup(Hero)).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 { Image } = createImages({ images, public: ['website/'] }) + renderToStaticMarkup(Hero) + await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining(hint))) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'https://my-app.tlcdn.com/builtin%2Fpublic-preview%400.0.2/AAAAAAAAAAAAAAAAAAAAAA', + ), + ) + 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 { Image } = createImages({ images, public: ['website/'] }) + renderToStaticMarkup(Hero) + await vi.waitFor(() => + expect(info).toHaveBeenCalledWith( + expect.stringContaining( + 'https://my-app.tlcdn.com/builtin%2Fpublic-preview%400.0.2/AAAAAAAAAAAAAAAAAAAAAA', + ), + ), + ) + 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..30284ddb --- /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( + '[Image] 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..b4c0e78b --- /dev/null +++ b/packages/img/test/storage-factory.test.tsx @@ -0,0 +1,653 @@ +// @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 storageReference = vi.hoisted(() => ({ + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', +})) + +const { connection, builtin } = vi.hoisted(() => ({ + connection: vi.fn(async () => undefined), + builtin: { template: 'builtin/storage-preview@0.0.3' }, +})) + +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 { createImages } from '../src/next/server.tsx' + +const images = { + 'website/hero.jpg': { ...storageReference, path: 'website/hero.jpg', width: 2400, height: 1600 }, + 'logo.png': { ...storageReference, path: 'logo.png', width: 64, height: 64 }, +} + +function imagesForWorkspace(workspace: string): typeof images { + return { + 'website/hero.jpg': { ...images['website/hero.jpg'], workspace }, + 'logo.png': { ...images['logo.png'], workspace }, + } +} + +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 { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Narrow, + ) + expect(markup).toContain(thumbHashToDataURL(bytes)) +}) + +test('public blur decodes the receipt on the server without changing image URLs or native attributes', () => { + const { Image } = createImages({ images, public: ['website/'] }) + const src = { ...images['website/hero.jpg'], thumbhash } + const markup = renderToStaticMarkup(Blurred hero) + 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(Hero))) +}) + +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 { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Hero, + ) + 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 { Image } = createImages({ images, public: ['website/'] }) + const hash = Buffer.from(rgbaToThumbHash(1, 1, [45, 110, 160, 128])).toString('base64') + const markup = renderToStaticMarkup( + Alpha, + ) + expect(markup).not.toContain('data:image/') +}) + +test.each([ + 'contain', + 'none', + 'scale-down', +] as const)('blur cannot remain beside a letterboxed %s image', (objectFit) => { + const { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Letterboxed, + ) + 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 { Image } = createImages({ images, authorize }) + const markup = renderToStaticMarkup( + Private, + ) + 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 { Image } = createImages({ + images: { 'website/hero.jpg': { ...images['website/hero.jpg'], thumbhash, hasAlpha: true } }, + public: ['website/'], + }) + const markup = renderToStaticMarkup( + Transparent hero, + ) + 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.3' + 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: imagesForWorkspace('catalog-app'), + } + const { Image } = createImages(catalog) + expect(firstUrl(renderToStaticMarkup(Hero)).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: imagesForWorkspace('catalog-app'), + delivery: { + baseUrl: 'http://127.0.0.1:32189/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + } + const integration = createImages(catalog) + const url = firstUrl( + renderToStaticMarkup(), + ) + expect(url.origin).toBe('http://127.0.0.1:32189') + expect(url.searchParams.get('cdn')).toBe('required') + const overridden = createImages({ + ...catalog, + baseUrl: 'https://images.example/{workspace}', + }) + expect( + firstUrl(renderToStaticMarkup()).origin, + ).toBe('https://images.example') +}) + +test('an explicit workspace cannot be replaced by a different environment default', () => { + vi.stubEnv('TRANSLOADIT_WORKSPACE', 'override-app') + const { Image } = createImages({ + workspace: 'catalog-app', + public: ['website/'], + images: imagesForWorkspace('catalog-app'), + }) + expect(firstUrl(renderToStaticMarkup(Hero)).hostname).toBe( + 'catalog-app.tlcdn.com', + ) +}) + +test('width is constrained by default and priority reserves an eager high-priority preload', () => { + const { Image } = createImages({ + workspace: 'my-app', + public: ['website/'], + images, + }) + const document = new DOMParser().parseFromString( + renderToStaticMarkup(Hero), + '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 { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Small, + ) + 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 = createImages({ + images, + authorize: () => true, + cacheMaxAge: '1m', + rotationInterval: '10m', + lifetime: '1h', + }) + const legacy = createImages({ + 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.imageRoute(new Request(url)) + expect(response.headers.get('cache-control')).toBe('private, max-age=60') + expect(response.headers.get('location')).toBe( + (await legacy.imageRoute(new Request(url))).headers.get('location'), + ) +}) + +test('art direction derives a responsive box and permits an externally owned fill box', () => { + const { Image } = createImages({ images, public: ['website/'] }) + const aspectRatio = { '(max-width: 639px)': '9/16', default: '16/9' } + const markup = renderToStaticMarkup( + Hero, + ) + expect(markup).toContain('aspect-ratio:1.7777777777777777') + expect(markup).toContain('@media (max-width: 639px)') + expect(markup).toContain('aspect-ratio:0.5625') + const external = renderToStaticMarkup( + Hero, + ) + expect(external).not.toContain('aspect-ratio') +}) + +test('requires an explicit delivery choice, naming all three alternatives', () => { + expect(() => createImages({ images })).toThrow(/Choose public, authorize, or delivery: 'direct'/) +}) + +test('a recovered private catalog explains intentional publication without guessing public access', () => { + expect(() => createImages({ 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 { Image, imageRoute } = createImages({ + images: imagesForWorkspace('explicit-app'), + authKey: 'explicit-key', + authSecret: 'explicit-secret', + workspace: 'explicit-app', + authorize: () => true, + }) + const url = firstUrl(renderToStaticMarkup(Hero)) + const location = (await imageRoute(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(['createImages']) +}) + +test('public catalog images are static with unsigned direct URLs and no signing shell', () => { + const { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup( + Hero, + ) + 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 { Image } = createImages({ images, public: ['website/'] }) + const markup = renderToStaticMarkup(Hero) + 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 { Image } = createImages({ images, authorize: () => true }) + expect(renderToStaticMarkup(Logo)).toContain('width="64"') + expect( + renderToStaticMarkup( + DB, + ), + ).toContain('width="300"') + expect(() => + Image({ + src: { ...storageReference, path: 'private.jpg', width: 20, height: 20 }, + alt: 'Private', + }), + ).toThrow(/allowed/) +}) + +test('explicit scope still limits catalog paths and public declarations', () => { + const { Image } = createImages({ + images, + allowedPathPrefixes: [], + delivery: 'direct', + }) + expect(() => Image({ src: 'website/hero.jpg', alt: 'Denied' })).toThrow(/allowed/) + expect(() => + createImages({ images, allowedPathPrefixes: ['website/'], public: ['private/'] }), + ).toThrow(/public.*allowed/) +}) + +test('catalog keys must agree with their receipt paths and unknown keys never fall through', () => { + expect(() => createImages({ images: { 'public.jpg': images['website/hero.jpg'] } })).toThrow( + /catalog.*path/, + ) + const { Image } = createImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(Image, 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 { Image } = createImages({ images, delivery: 'direct' }) + expect(() => Reflect.apply(Image, undefined, [{ src: 'website/herp.jpg', alt: 'Hero' }])).toThrow( + /Storage image path "website\/herp.jpg".*Did you mean "website\/hero.jpg"/, + ) + expect(() => Reflect.apply(Image, undefined, [{ src: 'website/herp.jpg', alt: 'Hero' }])).toThrow( + 'npx transloadit storage store -- ./image.jpg website/herp.jpg', + ) + expect(() => + Reflect.apply(Image, 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 { Image } = createImages({ images: {}, delivery: 'direct' }) + expect(() => Reflect.apply(Image, 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 { Image } = createImages({ images, delivery: 'direct' }) + expect(() => Reflect.apply(Image, undefined, [{ src: 'website/new.jpg', alt: 'New' }])).toThrow( + 'For a custom catalog, add --receipts to the command', + ) + expect(() => Reflect.apply(Image, 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 { Image } = createImages({ images, delivery: 'direct' }) + const render = () => Reflect.apply(Image, 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 { Image } = createImages({ images, delivery: 'direct' }) + const render = () => Reflect.apply(Image, 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(() => createImages({ allowedPathPrefixes: [''] })).toThrow(/allowWorkspaceRoot/) + const { Image } = createImages({ + allowWorkspaceRoot: true, + authorize: () => true, + }) + expect(renderToStaticMarkup(Logo)).toContain('') +}) + +test.each([ + 'constructor', + 'toString', + '__proto__', +])('requires an own catalog entry for %s without excluding an explicitly stored file', (path) => { + const missing = createImages({ images, delivery: 'direct' }) + expect(() => + Reflect.apply(missing.Image, undefined, [{ src: path, alt: 'Missing image' }]), + ).toThrow(`Storage image path "${path}" is not in the configured catalog`) + const present = createImages({ + images: { [path]: { ...storageReference, 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(() => + createImages({ + images, + authorize: () => true, + lifetime: 60_000, + rotationIntervalMs, + }), + ).toThrow(/rotationIntervalMs.*half/) + expect(() => + createImages({ 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(() => + createImages({ + 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 { Image, imageRoute } = createImages({ + images, + authorize: () => true, + lifetime: 60_000, + }) + const url = firstUrl(renderToStaticMarkup(Hero)) + vi.setSystemTime(Date.now() + offset) + const response = await imageRoute(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 { Image, imageRoute } = createImages({ + images, + authorize: () => true, + lifetime: 60_000, + rotationIntervalMs: 30_000, + }) + const url = firstUrl(renderToStaticMarkup(Hero)) + vi.setSystemTime(Date.now() + 29_999) + const location = (await imageRoute(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 { Image, imageRoute } = createImages({ + images, + authorize: () => true, + public: ['website/'], + lifetime: '2d', + }) + const url = firstUrl(renderToStaticMarkup(Private logo)) + const location = (await imageRoute(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(() => createImages({ 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 = createImages(configuration) + const url = firstUrl(renderToStaticMarkup()) + builtin.template = 'builtin/storage-preview@0.0.3' + const current = createImages(configuration) + const response = await current.imageRoute(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 = createImages({ ...configuration, template: 'my-custom-preview' }) + expect((await custom.imageRoute(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 { Image } = createImages({ images, delivery: 'direct' }) + const onError = vi.fn() + const stream = await renderToReadableStream(Hero, { + 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 { Image } = createImages({ + authKey: 'explicit-key', + authSecret: 'explicit-secret', + workspace: 'explicit-app', + images: imagesForWorkspace('explicit-app'), + public: ['website/'], + }) + expect(firstUrl(renderToStaticMarkup(Hero)).hostname).toBe( + 'explicit-app.tlcdn.com', + ) +}) diff --git a/packages/img/test/storage-references.test.tsx b/packages/img/test/storage-references.test.tsx new file mode 100644 index 00000000..8869fa6a --- /dev/null +++ b/packages/img/test/storage-references.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, expect, test, vi } from 'vitest' + +vi.mock('next/server.js', () => ({ connection: async () => undefined })) +vi.mock('server-only', () => ({})) + +import { createTransloaditImageModel } from '../src/index.ts' +import { createImages } from '../src/next/server.tsx' + +const original = { + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', + path: 'website/photo.jpg', + width: 800, + height: 600, +} + +function source(markup: string): string { + const src = new DOMParser() + .parseFromString(markup, 'text/html') + .querySelector('img') + ?.getAttribute('src') + if (!src) throw new Error('Expected an image URL') + return src +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +test('the framework-neutral model sends the actual immutable reference', () => { + const sign = vi.fn( + (_request: Parameters[1]>[0]) => '/preview.jpg', + ) + createTransloaditImageModel({ src: original, expiresAt: undefined }, sign) + expect(sign.mock.calls[0]?.[0]).toMatchObject({ + input: original.asset_id, + template: 'builtin/storage-preview@0.0.3', + urlParams: { v: original.version_id }, + }) +}) + +test('a public receipt keeps the exact same URL after rename and changes only on a new version', () => { + const config = { workspace: 'my-app', public: ['website/'], allowedPathPrefixes: ['website/'] } + const first = createImages(config) + const before = source(renderToStaticMarkup()) + const after = source( + renderToStaticMarkup( + , + ), + ) + expect(after).toBe(before) + expect(parseSmartCdnUrl(before)).toMatchObject({ + input: original.asset_id, + template: 'builtin/public-preview@0.0.2', + urlParams: { v: original.version_id }, + }) + const updated = source( + renderToStaticMarkup( + , + ), + ) + expect(updated).not.toBe(before) +}) + +test('private capabilities bind both asset and version, independently of a changed catalog', async () => { + const authorize = vi.fn(() => true) + const config = { + workspace: 'my-app', + authKey: 'key', + authSecret: 'secret', + allowedPathPrefixes: ['website/'], + authorize, + } + const first = createImages({ ...config, images: { [original.path]: original } }) + const url = source(renderToStaticMarkup()) + const newer = createImages({ + ...config, + images: { [original.path]: { ...original, version_id: 'C'.repeat(21) + 'A' } }, + }) + const response = await newer.imageRoute(new Request(new URL(url, 'https://app.example'))) + expect(response.status).toBe(307) + expect(parseSmartCdnUrl(response.headers.get('Location') ?? '')).toMatchObject({ + input: original.asset_id, + urlParams: { v: original.version_id }, + }) + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + path: original.path, + asset_id: original.asset_id, + version_id: original.version_id, + workspace: 'my-app', + }), + ) +}) + +test('Storage rejects missing identity and a receipt from a different Workspace', () => { + const { Image } = createImages({ + workspace: 'my-app', + public: ['website/'], + allowedPathPrefixes: ['website/'], + }) + expect(() => + renderToStaticMarkup( + Photo, + ), + ).toThrow(/receipt.*storage receipts sync/i) + expect(() => + renderToStaticMarkup(Photo), + ).toThrow(/Workspace/) +}) + +test('a custom private Template does not bypass the public Built-in Workspace boundary', () => { + const { Image } = createImages({ + workspace: 'my-app', + template: 'private-custom', + public: ['website/'], + allowedPathPrefixes: ['website/'], + }) + expect(() => + renderToStaticMarkup(Photo), + ).toThrow(/Workspace/) +}) + +test('versioned Built-ins reject unknown global parameters before emitting broken URLs', () => { + const { Image } = createImages({ + workspace: 'my-app', + public: ['website/'], + allowedPathPrefixes: ['website/'], + delivery: { urlParams: { cachebust: 'anything' } }, + }) + expect(() => renderToStaticMarkup(Photo)).toThrow( + /urlParams.*cachebust.*Built-in/, + ) +}) + +test('customer Templates can still accept their own global parameters', () => { + const { Image } = createImages({ + workspace: 'my-app', + template: 'private-custom', + publicTemplate: 'public-custom', + public: ['website/'], + allowedPathPrefixes: ['website/'], + delivery: { urlParams: { theme: 'dark' } }, + }) + expect( + parseSmartCdnUrl(source(renderToStaticMarkup(Photo))).urlParams, + ).toHaveProperty('theme', 'dark') +}) + +test('customer HTTP/S3 Templates retain path inputs and do not receive invented versions', () => { + const { Image } = createImages({ + workspace: 'my-app', + template: 'products', + publicTemplate: 'public-products', + public: ['website/'], + allowedPathPrefixes: ['website/'], + }) + const url = source( + renderToStaticMarkup( + Photo, + ), + ) + const parsed = parseSmartCdnUrl(url) + expect(parsed.input).toBe(original.path) + expect(parsed.urlParams).not.toHaveProperty('v') +}) diff --git a/packages/img/test/types.tsx b/packages/img/test/types.tsx index 9f52ca55..27a3ef8e 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,31 @@ import type { } from '../src/next/server.tsx' import { createTransloaditImageModel } from '../src/index.ts' +import { Image as ProjectImage, TransloaditPicture } from '../src/next/index.tsx' +import { createImages } 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 + +const templateSuspense = ( + // @ts-expect-error Conventional Templates can use redirects; use an explicit direct factory for Suspense. + +) +void templateSuspense // @ts-expect-error Storage preview formats use format-specific quality values, not a tuple. const modelWithTuple: TransloaditImageModelOptions = { ...modelOptions, formats: ['webp'] } @@ -42,17 +52,243 @@ 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 routeResponse = redirect.storageRoute(new Request('https://app.example/images')) +const routeResponse = redirect.imageRoute(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 = createImages({ + 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 = createImages({ + allowedPathPrefixes: ['documents/'], + delivery: 'direct', +}) +const images = { + 'website/hero.jpg': { path: 'website/hero.jpg', width: 2400, height: 1600 }, +} +const catalog = createImages({ 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 = createImages({ 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 []. +createImages({}) +const namedStorageImage = +void namedStorageImage +const fixedImage = ( + +) +const constrainedImage = ( + +) +const fillImage = ( + +) +const privateIntegration = createImages({ + 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.imageRoute +// A configured factory returns the same renderer name as the package-first API. +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 = createImages({ + allowedPathPrefixes: ['documents/'], + route: '/images', + authorize: () => true, +}) +const envImage = +const envRedirectImage = +const envRoute = envRedirect.imageRoute(new Request('https://app.example/images')) +// @ts-expect-error The environment helper requires explicit Storage policy, not guessed access. +createImages({}) +// @ts-expect-error Direct delivery does not expose an authorization route. +const envDirectRoute = envDirect.imageRoute +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 +const missingRoute = direct.imageRoute // @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 +301,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..fb4c6b36 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`) @@ -51,32 +51,31 @@ This package includes a full-featured CLI for interacting with Transloadit from ### Quick Start -Pick one auth setup and then run the CLI. - -Use shell env vars: +Log in through your browser, then run a command: ```bash -export TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" -export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" +npx -y @transloadit/node auth login +npx -y @transloadit/node auth status +npx -y @transloadit/node --help ``` -Or create a reusable home credentials file: +No account yet? Choose Sign up in the browser, create your workspace, then approve the code from +your terminal. Login saves a dedicated CLI key in the owner-only `~/.transloadit/credentials` file. +Use `auth login --no-browser` to open the printed link yourself; `auth login --stdin` imports dotenv +credentials without browser approval. Existing saved credentials require `--replace`. -```bash -mkdir -p ~/.transloadit -cat > ~/.transloadit/credentials <<'EOF' -TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" -TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" -EOF -chmod 600 ~/.transloadit/credentials -``` +### CI and manual credentials -Then see all available commands: +For automation, provide an appropriately scoped key through your CI secret manager: ```bash -npx -y @transloadit/node --help +export TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" +export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" ``` +For application servers, provision a separate deployment key rather than copying your CLI login. +`auth logout` revokes the saved CLI key; it should not interrupt a deployed application. + The CLI binary is still called `transloadit`, so command examples below may use `npx transloadit ...`. @@ -107,6 +106,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/viewer` 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 Image 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. +Storage receipts pin the returned `asset_id` and `version_id`. The image URL selects that exact +retained version, including after rename or overwrite; `v` is a real version, not a cache-busting hash. +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 or use the signed native catalog API with `dam:read` or `dam:write`. +Older API deployments may not yet expose these reads; see [Recovery options and prerequisites](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#recovery). + +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 +1444,140 @@ 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 `Image`. +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. + +### Read and reuse stored assets + +Save the complete stored result from `results[producingStep][i]` after `ASSEMBLY_COMPLETED`, including +its Workspace and returned path. Collision handling can rename the requested destination. +`storeImage()` returns this canonical record plus verified image metadata; other media use the +same `StoredAsset` contract without requiring image dimensions. + +```ts +const page = await client.listStoredAssets({ prefix: 'uploads/', limit: 100 }) +// Continue with { prefix: 'uploads/', cursor: page.next_cursor } while next_cursor is not null. +const current = await client.getStoredAsset(receipt.asset_id) +const pinned = await client.getStoredAsset(receipt.asset_id, { version_id: receipt.version_id }) +``` + +These signed, bounded metadata reads require `dam:read` or `dam:write`, use the key's Workspace, +and return checksums and dimensions when available. No S3 credentials or per-file HEADs are needed. + +For a multi-file or mixed-media Assembly, fetch its retained results on your server: + +```ts +// Load this upload from your database after checking the current user's access. +const outputs = await client.getStoredAssemblyResults({ + assemblyId: upload.assemblyId, + workspace: upload.workspace, +}) +for (const output of outputs) { + // Use a unique database key (assembly_id, step, result_id) so notification retries are harmless. + await registerMedia({ ...output, ownerId: upload.ownerId }) +} +``` + +Each output contains `asset` (the canonical record), `assembly_id`, `step`, `result_id`, and +`original_id` when the producing result supplies it. One input can produce an image, video and +poster; combined inputs can have an array of original IDs. Do not correlate by filename or array +position. Ordinary temporary results are omitted; malformed or cross-Workspace Storage records +fail the whole read. Failed/incomplete Assemblies are not reported as successful batches, even +if some writes already happened. This helper verifies authoritative metadata, **not application +ownership**: never use an unchecked browser/notification Assembly ID in place of `upload`. + +Rename/move with the native API to keep saved asset/version references working: + +```ts +const renamed = await client.moveStoredAsset(receipt.asset_id, { filename: 'ceremony.jpg' }) +// Supply destination_folder_id to change folders; null selects the Workspace root. +const deletion = await client.deleteStoredAsset(renamed.asset_id) +``` + +Mutations need `dam:write`. `moveStoredAsset()` returns the canonical record from the transaction +(checksums may be omitted), with the same identity and version. It never implements a move as +S3 copy/delete. A native folder move also keeps its descendants' identities. + +### Original media, playback and downloads + +`getStoredAssetUrl()` signs **exact original bytes**, not an image preview or a video transcode. +It accepts the canonical record for any media type, without requiring image dimensions: + +```ts +// In a server route, authenticate and load this exact saved reference from your own database. +const media = await loadMediaAuthorizedForUser(user, mediaId) +const url = client.getStoredAssetUrl(media.asset, { download: true }) +return new Response(null, { + status: 307, + headers: { Location: url, 'Cache-Control': 'private, no-store', Vary: 'Cookie' }, +}) +``` + +`download: true` uses the receipt's filename; a string overrides it safely. Omit `download` for +inline playback. The attachment header comes from the CDN response, including for cross-origin +links: no `fetch().blob()`, application byte proxy or whole-video memory allocation is needed. +Range requests support seeking when the original format/codecs work in that browser; generate +and store a separate compatible playback rendition when they do not. Use Viewer for previews. + +These URLs use signed `builtin/storage-serve@0.0.3` (deploy the matching API2 version first). +The signing key needs `smart_cdn:sign` or the Workspace's legacy Smart CDN-enabled key permission. +`lifetimeMs` defaults to a maximum of five minutes (allowed: one second to 48 hours). Signing +rotates at most once a minute and never more slowly than half the lifetime, so a new URL retains +at least half its requested lifetime. Bunny includes the full query in its cache key: each +rotation starts a separate cache entry. An issued URL remains usable until expiry even if the +application session is revoked. Reauthorize every redirect; do not cache private redirects in +a shared cache. A download filename changes the signed URL/cache key but never the selected bytes. + +For a later Assembly, choose exactly one `/transloadit/import` selector: + +- `{ robot: '/transloadit/import', path: receipt.path }` reads the current bytes at that location. +- `{ robot: '/transloadit/import', asset_id: receipt.asset_id }` follows the logical asset after moves. +- `{ robot: '/transloadit/import', asset_id: receipt.asset_id, version_id: receipt.version_id }` + selects the exact retained bytes. It never falls back to the current version. + +Authenticate for the saved Workspace. Deleting the asset or removing that retained version makes +the reference unavailable; an ID is neither a backup nor authorization. Use the actual stored IDs, +not a filename or digest, and keep application ownership associated with the stable asset ID. + +### 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 +1585,7 @@ import { Transloadit } from '@transloadit/node' const transloadit = new Transloadit({ authKey: 'YOUR_TRANSLOADIT_KEY', authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', }) try { @@ -1486,10 +1685,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 +2147,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..430926e8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -29,7 +29,7 @@ "author": "Tim Koschuetzki ", "packageManager": "yarn@4.12.0", "engines": { - "node": ">= 20" + "node": ">= 20.10.0" }, "dependencies": { "@transloadit/sev-logger": "^0.1.9", @@ -38,6 +38,7 @@ "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 +49,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..e05e7802 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 { @@ -12,6 +14,13 @@ import type { AssemblyIndexItem, AssemblyStatus, } from './alphalib/types/assemblyStatus.ts' +import type { + GetStoredAssetOptions, + ListStoredAssetsOptions, + MoveStoredAssetOptions, + StoredAsset, + StoredAssetsPage, +} from './alphalib/types/storageAsset.ts' import type { BaseResponse, BillResponse, @@ -38,6 +47,21 @@ 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 { + GetStoredAssemblyResultsOptions, + StoredAssemblyResult, + StoredAssetUrlOptions, +} from './storageResults.ts' import type { Stream, UploadBehavior } from './tus.ts' import * as assert from 'node:assert' @@ -52,19 +76,37 @@ 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' import { assemblyIndexSchema, assemblyStatusSchema } from './alphalib/types/assemblyStatus.ts' +import { + damAssetFoundResponseSchema, + damAssetGetOptionsSchema, + damAssetMoveOptionsSchema, + damAssetsListedResponseSchema, + damAssetsListOptionsSchema, + damIdSchema, + storedAssetSchema, +} from './alphalib/types/storageAsset.ts' import { zodParseWithContext } from './alphalib/zodParseWithContext.ts' import { mintBearerTokenWithCredentials } from './bearerToken.ts' 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 { getStoredAssemblyResults, getStoredAssetUrl } from './storageResults.ts' import { sendTusRequest } from './tus.ts' export type { @@ -76,6 +118,13 @@ export type { } from '@transloadit/utils' export type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts' +export type { + GetStoredAssetOptions, + ListStoredAssetsOptions, + MoveStoredAssetOptions, + StoredAsset, + StoredAssetsPage, +} from './alphalib/types/storageAsset.ts' export type { Base64Strategy, InputFile, @@ -93,6 +142,22 @@ 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 type { + GetStoredAssemblyResultsOptions, + StoredAssemblyResult, + StoredAssetUrlOptions, +} from './storageResults.ts' export { buildCompileAssemblyInstructionsSystemPrompt, @@ -382,6 +447,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 +460,7 @@ type BaseOptions = { export type Options = BaseOptions & (AuthKeySecret | AuthToken) export class Transloadit { + #signatureAlgorithm: SignatureAlgorithm private _authKey: string private _authSecret: string @@ -413,6 +481,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 +502,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 +523,126 @@ 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) + } + + /** Verifies a completed batch's retained media and preserves result/input provenance. */ + getStoredAssemblyResults( + options: GetStoredAssemblyResultsOptions, + ): Promise { + return getStoredAssemblyResults(this, options) + } + + /** Signs exact original bytes after your app authorizes the asset. Use a 307 for fresh request-time authorization. */ + getStoredAssetUrl(asset: StoredAsset, options?: StoredAssetUrlOptions): string { + return getStoredAssetUrl(this, asset, options) + } + + /** Reads a live asset's current version, or the exact retained version when version_id is set. */ + async getStoredAsset( + assetId: string, + options: GetStoredAssetOptions & { signal?: AbortSignal } = {}, + ): Promise { + const id = damIdSchema.parse(assetId) + const params = damAssetGetOptionsSchema.parse(options) + const result = await this._remoteJson({ + urlSuffix: `/dam/assets/${id}`, + method: 'get', + params, + signal: options.signal, + }) + checkResult(result) + const { asset } = damAssetFoundResponseSchema.parse(result) + if ( + asset.asset_id !== id || + (params.version_id !== undefined && asset.version_id !== params.version_id) + ) { + throw new InconsistentResponseError( + 'The response did not match the requested Storage reference', + ) + } + return asset + } + + /** Reads one bounded catalog page without S3 credentials or a HEAD request per object. */ + async listStoredAssets( + options: ListStoredAssetsOptions & { signal?: AbortSignal } = {}, + ): Promise { + const params = damAssetsListOptionsSchema.parse(options) + const result = await this._remoteJson({ + urlSuffix: '/dam/assets', + method: 'get', + params, + signal: options.signal, + }) + checkResult(result) + const page = damAssetsListedResponseSchema.parse(result) + if (page.assets.some((asset) => asset.workspace !== page.workspace)) { + throw new InconsistentResponseError('Storage catalog page contains a different Workspace') + } + return page + } + + /** Moves or renames an asset natively, preserving asset and version identities. */ + async moveStoredAsset( + assetId: string, + options: MoveStoredAssetOptions & { signal?: AbortSignal }, + ): Promise { + const id = damIdSchema.parse(assetId) + const params = damAssetMoveOptionsSchema.parse(options) + if (params.filename === undefined && params.destination_folder_id === undefined) { + throw new TypeError('Provide filename or destination_folder_id for a Storage move') + } + const result = await this._remoteJson({ + urlSuffix: `/dam/assets/${id}`, + method: 'patch', + params, + signal: options.signal, + }) + checkResult(result) + const { asset } = z + .object({ ok: z.literal('DAM_ASSET_MOVED'), asset: storedAssetSchema }) + .parse(result) + if (asset.asset_id !== id) + throw new InconsistentResponseError( + 'The response did not match the requested Storage reference', + ) + return asset + } + + /** Soft-deletes an asset by identity, making retained versions unavailable for new reads. */ + async deleteStoredAsset( + assetId: string, + options: { signal?: AbortSignal } = {}, + ): Promise<{ asset_id: string; deleted_at: string }> { + const id = damIdSchema.parse(assetId) + const result = await this._remoteJson({ + urlSuffix: `/dam/assets/${id}`, + method: 'delete', + signal: options.signal, + }) + checkResult(result) + const receipt = z + .object({ + ok: z.literal('DAM_ASSET_DELETED'), + asset_id: damIdSchema, + deleted_at: z.string().datetime(), + }) + .parse(result) + if (receipt.asset_id !== id) + throw new InconsistentResponseError( + 'The response did not match the requested Storage reference', + ) + return { asset_id: receipt.asset_id, deleted_at: receipt.deleted_at } + } + /** * Create an Assembly * @@ -1199,6 +1389,20 @@ export class Transloadit { }) } + /** Revoke the signing Auth Key itself, without granting access to other workspace keys. */ + async revokeOwnAuthKey(): Promise { + const result = await this._remoteJson< + unknown, + OptionalAuthParams & { action: 'revoke_auth_key' } + >({ + urlSuffix: '/auth_keys/self', + method: 'delete', + params: { action: 'revoke_auth_key' }, + }) + checkResult(result) + z.object({ ok: z.literal('AUTH_KEY_DELETED') }).parse(result) + } + /** * Get an Assembly Template * @@ -1232,6 +1436,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 +1521,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.') } @@ -1357,7 +1604,7 @@ export class Transloadit { url?: string isTrustedUrl?: boolean timeout?: Delays - method?: 'delete' | 'get' | 'post' | 'put' + method?: 'delete' | 'get' | 'patch' | 'post' | 'put' params?: TParams fields?: Fields headers?: Headers @@ -1397,7 +1644,7 @@ export class Transloadit { for (let retryCount = 0; ; retryCount++) { let form: FormData | undefined - if (method === 'post' || method === 'put' || method === 'delete') { + if (method === 'post' || method === 'patch' || method === 'put' || method === 'delete') { form = new FormData() this._appendForm(form, params, fields) } diff --git a/packages/node/src/alphalib/types/assemblyStatus.ts b/packages/node/src/alphalib/types/assemblyStatus.ts index de740f06..758b1132 100644 --- a/packages/node/src/alphalib/types/assemblyStatus.ts +++ b/packages/node/src/alphalib/types/assemblyStatus.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { fileAsSchema } from './file.ts' +import { storedAssetSchema } from './storageAsset.ts' export const assemblyBusyCodeSchema = z.enum([ 'ASSEMBLY_UPLOADING', @@ -337,6 +338,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', @@ -623,6 +632,10 @@ const hlsPlaylistSchema = z.object({ export const assemblyStatusUploadSchema = z .object({ id: z.string(), + asset_id: storedAssetSchema.shape.asset_id.optional(), + version_id: storedAssetSchema.shape.version_id.optional(), + workspace: storedAssetSchema.shape.workspace.optional(), + sha256: storedAssetSchema.shape.sha256, name: z.string(), basename: z.string(), ext: z.string(), @@ -660,6 +673,10 @@ export type AssemblyStatusUploads = z.infer export const assemblyStatusResultSchema = z .object({ id: z.string().optional(), + asset_id: z.string().optional(), + version_id: storedAssetSchema.shape.version_id.optional(), + workspace: storedAssetSchema.shape.workspace.optional(), + sha256: storedAssetSchema.shape.sha256, 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/file-serve.ts b/packages/node/src/alphalib/types/robots/file-serve.ts index 6340a9ba..7a5e86f3 100644 --- a/packages/node/src/alphalib/types/robots/file-serve.ts +++ b/packages/node/src/alphalib/types/robots/file-serve.ts @@ -66,11 +66,11 @@ Also consider configuring caching headers and cache-control directives to contro You can leverage [signed Smart CDN URLs](/docs/api/authentication/#smart-cdn) to avoid abuse of our encoding platform. Below is a quick Node.js example using our Node SDK, but there are [examples for other languages and SDKs](/docs/api/authentication/#example-code) as well. \`\`\`javascript -// yarn add transloadit +// yarn add @transloadit/node // or -// npm install --save transloadit +// npm install --save @transloadit/node -import { Transloadit } from 'transloadit' +import { Transloadit } from '@transloadit/node' const transloadit = new Transloadit({ authKey: 'YOUR_TRANSLOADIT_KEY', @@ -108,6 +108,14 @@ An optional duration in seconds that the served file should be cached. When set, This is useful for controlling data retention in CDNs. For instance, if your temporary files are deleted after 24 hours, you can set \`cache_duration\` to \`86400\` to ensure cached copies also expire within that window. `), + download_name: z + .string() + // biome-ignore lint/suspicious/noControlCharactersInRegex: reject header-injection control characters, not literal text. + .regex(/^[^/\\\u0000-\u001f\u007f-\u009f]{0,255}$/u) + .optional() + .describe( + 'Serve as an attachment using this Unicode filename. Empty or omitted keeps inline delivery. Overrides a Content-Disposition header without changing the bytes or Range support.', + ), headers: z .record(z.string()) .default({ 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..b6d09694 --- /dev/null +++ b/packages/node/src/alphalib/types/robots/transloadit-import.ts @@ -0,0 +1,154 @@ +import type { RobotMetaInput } from './_instructions-primitives.ts' + +import { z } from 'zod' + +import { damIdSchema } from '../storageAsset.ts' +import { + booleanStringSchema, + interpolateRobot, + interpolationSchemaFull, + recursive, + robotBase, + robotImport, +} from './_instructions-primitives.ts' + +/** Cross-field validation runs after interpolation-aware parsing at the Assembly Step boundary. */ +export function refineTransloaditImportSelector( + step: { + robot: string + path?: unknown + asset_id?: unknown + version_id?: unknown + recursive?: unknown + }, + context: z.RefinementCtx, +): void { + if (step.robot !== '/transloadit/import') return + const hasPath = step.path !== undefined + const hasAsset = step.asset_id !== undefined + if (hasPath === hasAsset || (step.version_id !== undefined && !hasAsset)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [step.version_id !== undefined && !hasAsset ? 'version_id' : 'asset_id'], + message: 'Select either path, or asset_id with an optional version_id.', + }) + } + if (hasAsset && step.recursive === true) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['recursive'], + message: 'Recursive imports require a path, not an asset_id.', + }) + } +} + +const recursiveDescription = ` +Whether to import files from subfolders and sub-subfolders when \`path\` is a folder. By default +only the folder's own files are imported. +` + +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 files from your Workspace's Transloadit Storage. Select a mutable location with +\`path\`, or a logical asset with \`asset_id\`. Add \`version_id\` to pin the exact stored bytes. +IDs remain subject to Workspace access and version retention. +`), + path: z + .string() + .optional() + .describe(` +The current file or folder location, for example \`photos/cat.jpg\`. Use either \`path\` or +\`asset_id\`, not both. Renaming makes an old path stale; overwriting changes what it imports. +`), + asset_id: damIdSchema.optional().describe(` +The stored asset's stable ID. Without \`version_id\`, imports its current version even after +renaming or moving it. Cannot be combined with \`path\` or recursive folder imports. +`), + version_id: damIdSchema.optional().describe(` +The exact retained version of \`asset_id\` to import. Requires \`asset_id\`; a missing or deleted +version never falls back to the current version. +`), + recursive: recursive.describe(recursiveDescription), + }) + .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, +).extend({ + // The generic boolean interpolator changes literal false to true. Keep Storage's documented + // false default intact; unresolved variables are validated after uploader interpolation. + recursive: z + .union([ + recursive, + booleanStringSchema.transform((value) => value === 'true'), + interpolationSchemaFull, + ]) + .describe(recursiveDescription), +}) +export type InterpolatableRobotTransloaditImportInstructions = + InterpolatableRobotTransloaditImportInstructionsInput + +export type InterpolatableRobotTransloaditImportInstructionsInput = z.input< + typeof interpolatableRobotTransloaditImportInstructionsSchema +> + +export const interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema = + interpolateRobot(robotTransloaditImportInstructionsWithHiddenFieldsSchema).extend({ + recursive: interpolatableRobotTransloaditImportInstructionsSchema.shape.recursive, + }) +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..f9f9f968 --- /dev/null +++ b/packages/node/src/alphalib/types/robots/transloadit-store.ts @@ -0,0 +1,98 @@ +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('error') + .describe(` +Chooses how to handle an existing destination. By default, an occupied path fails. Select +\`rename\` to allocate a different filename, or \`overwrite\` to create a new version of the +existing asset. Always save the returned asset identity, version and final path. +`), + 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/alphalib/types/storageAsset.ts b/packages/node/src/alphalib/types/storageAsset.ts new file mode 100644 index 00000000..62292886 --- /dev/null +++ b/packages/node/src/alphalib/types/storageAsset.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' + +/** Maximum normalized Storage path length supported by the catalog schema. */ +export const damPathMaxCodePoints = 512 + +// A Unicode regexp counts code points and stays portable across the published Zod 3/4 schemas. +const boundedPathSchema = z.string().regex(new RegExp(`^[\\s\\S]{0,${damPathMaxCodePoints}}$`, 'u')) + +/** Canonical path and JSON representation of a 128-bit Storage identifier. */ +export const damIdPattern = /^[A-Za-z0-9_-]{21}[AQgw]$/u + +/** Canonical public Storage asset, version and folder identifier. */ +export const damIdSchema = z + .string() + .regex(damIdPattern) + .describe('Case-sensitive, canonical 22-character Base64URL Storage identifier.') + +/** A retained version of a live Workspace asset, independent of its mutable location. */ +export const storedAssetSchema = z.object({ + workspace: z.string().min(1), + asset_id: damIdSchema, + version_id: damIdSchema, + path: z.string().min(1), + size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + mime: z.string().nullable(), + md5hash: z + .string() + .regex(/^[a-f0-9]{32}$/u) + .optional(), + sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .optional(), + width: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), + height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), +}) + +/** Store, read and recovery share this shape; image clients may enrich it with placeholders. */ +export type StoredAsset = z.infer + +/** Bounded metadata paging; cursors are the last returned, case-sensitive asset path. */ +export const damAssetsListOptionsSchema = z.object({ + prefix: boundedPathSchema.default(''), + cursor: boundedPathSchema.min(1).optional(), + limit: z.number().int().min(1).max(500).default(100), +}) + +/** An omitted version selects the current version; an explicit version never falls back. */ +export const damAssetGetOptionsSchema = z.object({ version_id: damIdSchema.optional() }) + +/** Omit the destination to rename in place; null moves to the Workspace root. */ +export const damAssetMoveOptionsSchema = z.object({ + destination_folder_id: damIdSchema.nullable().optional(), + filename: z + .string() + .regex(/^[^/\\\p{Cc}]{1,255}$/u) + .refine( + (name) => name.trim() === name && name !== '.' && name !== '..', + 'Use a filename, not a path', + ) + .optional(), +}) + +export type MoveStoredAssetOptions = z.input + +/** Native metadata is the same version-pinned shape returned by storing a file. */ +export const damAssetFoundResponseSchema = z + .object({ + message: z.string().min(1), + ok: z.literal('DAM_ASSET_FOUND'), + asset: storedAssetSchema, + }) + .strict() + +/** A bounded catalog page; null means there is no next page, even in an empty Workspace. */ +export const damAssetsListedResponseSchema = z + .object({ + workspace: storedAssetSchema.shape.workspace, + message: z.string().min(1), + ok: z.literal('DAM_ASSETS_LISTED'), + assets: z.array(storedAssetSchema), + next_cursor: z.string().nullable(), + }) + .strict() + +export type StoredAssetsPage = z.infer +export type ListStoredAssetsOptions = z.input +export type GetStoredAssetOptions = z.input diff --git a/packages/node/src/alphalib/types/template.ts b/packages/node/src/alphalib/types/template.ts index 9b466f19..693028c8 100644 --- a/packages/node/src/alphalib/types/template.ts +++ b/packages/node/src/alphalib/types/template.ts @@ -9,6 +9,7 @@ import type { RobotsWithHiddenBotsAndFields, } from './robots/_index.ts' import type { RobotUse } from './robots/_instructions-primitives.ts' +import { refineTransloaditImportSelector } from './robots/transloadit-import.ts' export type Step = RobotsSchema export type StepInput = Step @@ -24,6 +25,7 @@ export const stepSchema: z.ZodType = z .describe('Identifier of the [robot](https://transloadit.com/docs/robots/) to execute'), }) .and(robotsSchema) + .superRefine(refineTransloaditImportSelector) export const stepsSchema: z.ZodType = z .record(stepSchema) .describe('Contains Assembly Instructions.') @@ -42,6 +44,7 @@ export const stepSchemaWithHiddenFields: z.ZodType = z .describe('Identifier of the [robot](https://transloadit.com/docs/robots/) to execute'), }) .and(robotsWithHiddenBotsAndFieldsSchema) + .superRefine(refineTransloaditImportSelector) export const stepsSchemaWithHiddenFields: z.ZodType = z .record(stepSchemaWithHiddenFields) .describe('Contains Assembly Instructions.') 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/assemblies.ts b/packages/node/src/cli/commands/assemblies.ts index 152fab8e..5b0f1e86 100644 --- a/packages/node/src/cli/commands/assemblies.ts +++ b/packages/node/src/cli/commands/assemblies.ts @@ -132,9 +132,11 @@ function parseTemplateFieldAssignments( return fieldsMap } -const AssemblySchema = z.object({ - id: z.string(), -}) +const AssemblySchema = z + .object({ + id: z.string(), + }) + .passthrough() // --- Business logic functions (from assemblies.ts) --- @@ -163,8 +165,14 @@ export function list( if (fields == null) { output.print(parsed.data.id, assembly) } else { - const assemblyRecord = assembly as Record - output.print(fields.map((field) => assemblyRecord[field]).join(' '), assembly) + const record = parsed.data + const selected = fields.filter((field) => Object.hasOwn(record, field)) + output.print( + fields + .map((field) => (Object.hasOwn(record, field) ? record[field] : undefined)) + .join(' '), + Object.fromEntries(selected.map((field) => [field, record[field]])), + ) } } }) 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..160629a6 --- /dev/null +++ b/packages/node/src/cli/commands/image.ts @@ -0,0 +1,282 @@ +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 { resolveStorageWorkspace } from '../storageCatalog.ts' +import { storagePublicError } from '../storagePublic.ts' +import { + assertStorageCatalogOrigin, + defaultStorageCatalog, + readStorageCatalog, + storageCatalogDelivery, + storageTypesPath, + updateStorageReceipts, +} from '../storageReceipts.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: 'Copy an imported application key to 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 createdCatalog = false + 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 createImages 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 && this.privateDelivery) + 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) { + if (saved.credentialsLoginMethod === 'device') + throw new Error( + 'Use a separate application key for private rendering. The device-login key is revoked by auth logout. Omit --write-env and configure TRANSLOADIT_KEY and TRANSLOADIT_SECRET in your application environment. Nothing was written.', + ) + 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 { AuthorizeTransloaditImage } from '@transloadit/viewer/next/server'", + '', + '// Replace with your session and per-object authorization for workspace, template and path.', + 'export const authorize: AuthorizeTransloaditImage = () => false', + '', + ].join('\n'), + }, + { + path: `${root}app/api/storage-images/route.ts`, + content: "export { GET, HEAD } from '@transloadit/viewer/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) => { + // Offline example/authorizer scaffolding preserves the caller's catalog without reading + // remote metadata, publishing, saving credentials or choosing a different API environment. + if (needsCredentials || this.endpoint !== undefined) + assertStorageCatalogOrigin( + previous, + new URL(this.endpoint ?? login.endpoint ?? 'https://api2.transloadit.com').origin, + this.receipts, + ) + 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, + apiOrigin: + previous?.apiOrigin ?? + (needsCredentials || this.endpoint !== undefined + ? new URL(this.endpoint ?? login.endpoint ?? 'https://api2.transloadit.com').origin + : undefined), + 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 ?? {}, + } + }) + // The catalog is shared Storage state: another command may update it after this lock ends. + // Never roll it back when unrelated scaffold-file creation fails. + createdCatalog = catalog === undefined + 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 for workspace, template and path 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)} }`}.` + const filesCreated = [ + ...(createdCatalog ? [this.receipts, storageTypesPath(this.receipts)] : []), + ...created, + ] + this.output.print( + `Created ${filesCreated.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: filesCreated, environment: envBlock }, + ) + return undefined + } catch (error) { + if (createdCatalog) + this.output.error( + `The catalog ${JSON.stringify(this.receipts)} and its declarations were preserved; another Storage command may already be using them.`, + ) + 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..a7bbc3f1 --- /dev/null +++ b/packages/node/src/cli/commands/login.ts @@ -0,0 +1,334 @@ +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, + parseCliLoginSnapshot, + 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 = parseCliLoginSnapshot(before) + 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..b8bb0f3f --- /dev/null +++ b/packages/node/src/cli/commands/storage.ts @@ -0,0 +1,733 @@ +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 { z } from 'zod' + +import { ApiError } from '../../ApiError.ts' +import { storedAssetSchema } from '../../alphalib/types/storageAsset.ts' +import InconsistentResponseError from '../../InconsistentResponseError.ts' +import { normalizeStoragePublicPrefix } from '../../storagePublicPrefixes.ts' +import { Transloadit } from '../../Transloadit.ts' +import { noticeCliCredentialSource, quoteCliArgument, resolveCliConfig } from '../helpers.ts' +import { + listStorageAssets, + resolveStorageWorkspace, + withStorageCatalog, +} from '../storageCatalog.ts' +import { storagePublicError } from '../storagePublic.ts' +import { + assertStorageCatalogOrigin, + assertStorageWorkspace, + defaultStorageCatalog, + readStorageCatalog, + storageCatalogDelivery, + storageTypesPath, + updateStorageReceipts, +} from '../storageReceipts.ts' +import { storageImageConfigAdvice, storageImagePrivateAdvice } from '../storageSnippets.ts' +import { ensureError } from '../types.ts' +import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts' + +abstract class StorageProjectCommand extends AuthenticatedCommand { + protected get apiOrigin(): string { + return new URL(this.endpoint ?? this.cliConfig.endpoint ?? 'https://api2.transloadit.com') + .origin + } + 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 withStorageCatalog( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: catalog?.workspace, + }, + (client, workspace) => listStorageAssets(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, + ) + if (previous?.workspace === workspace) + assertStorageCatalogOrigin(previous, this.apiOrigin, this.receipts) + 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, + apiOrigin: this.apiOrigin, + 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, + ) + if (previous?.workspace === workspace) + assertStorageCatalogOrigin(previous, this.apiOrigin, this.receipts) + 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, + apiOrigin: this.apiOrigin, + 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 Image', + 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.apiOrigin + 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) + if (receipts?.workspace === workspace) + assertStorageCatalogOrigin(receipts, apiOrigin, this.receipts) + signal.throwIfAborted() + if (this.hashed) { + // This pass chooses the path and deduplicates. storeImage independently verifies its + // upload input; sharing a caller-supplied checksum would need an immutable-file contract. + 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)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity from the same API environment, then retry; 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 (stored.receipt.workspace !== workspace) + throw new Error( + `The stored receipt belongs to Workspace ${stored.receipt.workspace}, not ${workspace}. The project catalog was not changed; inspect the Assembly before retrying.`, + ) + if (!unchanged) + stored.receipt = { + ...stored.receipt, + ...(this.hashed ? { 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, + apiOrigin, + 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) => { + if (previous?.workspace === workspace) + assertStorageCatalogOrigin(previous, apiOrigin, this.receipts) + 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, ' '), + ) + const renderAdvice = saved + ? `Render it with ${alt}\nReplace alt with a description (or an empty string for a decorative image).${setupAdvice}` + : 'Use --receipts for a separate catalog before rendering images from this workspace.' + 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.`}\n${renderAdvice}`, + 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 native Storage catalog API. On an older deployment without these routes, 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 version-pinned metadata through the authenticated native Storage catalog. */ +export class StorageListCommand extends UnauthenticatedCommand { + static override paths = [['storage', 'ls']] + static override usage = Command.Usage({ + category: 'Storage', + description: 'List stored paths and sizes; include asset/version identities with --json', + details: + 'Uses an Auth Key with dam:read or dam:write scope and bounded native catalog pages. Verifies the Workspace against the selected credentials. No Assembly, S3 request or original download is needed.', + 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 withStorageCatalog( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: catalog?.workspace, + }, + (client, workspace) => listStorageAssets(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 uploadEvidenceSchema = storedAssetSchema.extend({ + thumbhash: z.string().max(48).optional(), + hasAlpha: z.boolean().optional(), + source: z.string().optional(), + apiOrigin: z.string().url().optional(), +}) + +const hashedReceiptSchema = uploadEvidenceSchema.extend({ + md5hash: storedAssetSchema.shape.md5hash.unwrap(), + width: storedAssetSchema.shape.width.unwrap(), + height: storedAssetSchema.shape.height.unwrap(), +}) + +/** Recovers pinned receipts from bounded native catalog pages 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 native catalog pages and dam:write scope to also recover declared public prefixes. + Adds or refreshes matched paths; never prunes unmatched entries. Local placeholder metadata + survives only for the same retained version. Every matched image must have catalog dimensions. + Any failure preserves the previous file. No Assembly, original download or remote write occurs. + `, + 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 + let apiOrigin: string | undefined + synced = await withStorageCatalog( + { + endpoint: this.endpoint, + workspace: this.workspace, + projectWorkspace: previous?.workspace, + signal, + }, + async (client, workspace, endpoint) => { + actualWorkspace = workspace + apiOrigin = endpoint + if (previous?.workspace === workspace) + assertStorageCatalogOrigin(previous, endpoint, this.receipts) + const assets = await listStorageAssets(client, workspace, this.prefix, signal) + const entries = assets.map((asset) => { + try { + validateStoragePath(asset.path) + } catch (cause) { + throw new Error( + `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`, + { cause }, + ) + } + if (asset.width === undefined || asset.height === undefined) { + throw new Error( + `Storage image ${JSON.stringify(asset.path)} needs positive catalog width and height. Select an image-only prefix and backfill missing dimensions before retrying.`, + ) + } + const evidence = uploadEvidenceSchema.safeParse( + previous?.workspace === workspace && Object.hasOwn(previous.images, asset.path) + ? previous.images[asset.path] + : undefined, + ) + const retained = + evidence.success && + evidence.data.asset_id === asset.asset_id && + evidence.data.version_id === asset.version_id + ? evidence.data + : {} + return [asset.path, { ...retained, ...asset, apiOrigin: endpoint }] + }) + 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 catalog 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, + apiOrigin, + 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/commands/templates.ts b/packages/node/src/cli/commands/templates.ts index 1e3f9cd2..14e0e17e 100644 --- a/packages/node/src/cli/commands/templates.ts +++ b/packages/node/src/cli/commands/templates.ts @@ -152,9 +152,11 @@ async function deleteTemplates( // Export with `delete` alias for external consumers export { deleteTemplates as delete } -const TemplateIdSchema = z.object({ - id: z.string(), -}) +const TemplateIdSchema = z + .object({ + id: z.string(), + }) + .passthrough() const INCLUDE_BUILTIN_VALUES = ['all', 'latest', 'exclusively-all', 'exclusively-latest'] as const @@ -190,11 +192,11 @@ async function list( const parsed = TemplateIdSchema.safeParse(item) if (!parsed.success) continue - let template: unknown = item + let template = parsed.data if (includeContent) { try { const full = await client.getTemplate(parsed.data.id) - template = { ...item, content: full.content } + template = { ...template, content: full.content } } catch (err) { output.error(formatAPIError(err)) } @@ -203,8 +205,13 @@ async function list( if (fields == null) { output.print(parsed.data.id, template) } else { - const templateRecord = template as Record - output.print(fields.map((field) => templateRecord[field]).join(' '), template) + const selected = fields.filter((field) => Object.hasOwn(template, field)) + output.print( + fields + .map((field) => (Object.hasOwn(template, field) ? template[field] : undefined)) + .join(' '), + Object.fromEntries(selected.map((field) => [field, template[field]])), + ) } } 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..0e6f264c 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,17 @@ 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 + credentialsLoginMethod?: string endpoint?: string loadError?: string } @@ -41,13 +61,38 @@ 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. + try { + const home = homedir() + if (path.isAbsolute(home)) return home + const fallback = userInfo().homedir + if (path.isAbsolute(fallback)) return fallback + } catch (error) { + throw new Error( + 'Cannot determine a safe home directory. Set TRANSLOADIT_CREDENTIALS_FILE to an explicit path.', + { cause: error }, + ) + } + throw new Error( + 'Cannot determine a safe home directory. Set TRANSLOADIT_CREDENTIALS_FILE to an explicit path.', + ) +} + +/** 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 +157,15 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined { const projectDotenvPath = getProjectDotenvPath() if (loadedProjectDotenvPath !== projectDotenvPath) { restoreProjectDotenvFromProcessEnv() - shellEnvBeforeProjectDotenv = { ...process.env } + shellEnvBeforeProjectDotenv = { + values: { ...process.env }, + // An explicit file makes OS home discovery unnecessary (e.g. an unmapped container UID). + // Otherwise capture it before dotenv can replace HOME and redirect a newly saved login. + homeDirectory: + normalizeEnvValue(process.env.TRANSLOADIT_CREDENTIALS_FILE) == null + ? credentialHomeDirectory() + : undefined, + } loadedProjectDotenvPath = projectDotenvPath } @@ -144,7 +197,7 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined { function getShellEnvValues(): Record { if (loadedProjectDotenvPath === getProjectDotenvPath() && shellEnvBeforeProjectDotenv != null) { - return shellEnvBeforeProjectDotenv + return shellEnvBeforeProjectDotenv.values } return { ...process.env } @@ -214,12 +267,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 +304,127 @@ 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.`, + ) +} + +/** Resolve one already-read saved credential snapshot, without consulting the environment again. */ +export function parseCliLoginSnapshot(contents: string): ResolvedCliConfig { + return resolveLoginSource({ name: 'credentialsFile', values: parseDotenv(contents) }) +} + +function resolveLoginSource(source: CliEnvSource): ResolvedCliConfig { + const credentials = getSourceCredentials(source) + if (credentials !== undefined && 'loadError' in credentials) return credentials + const endpoint = getSourceValue(source, ['TRANSLOADIT_ENDPOINT']) + return { + auth: credentials, + authSource: 'saved login', + authWorkspace: getSourceValue(source, ['TRANSLOADIT_WORKSPACE']), + authWorkspaceVerified: getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true', + credentials, + credentialsSource: 'saved login', + credentialsWorkspace: getSourceValue(source, ['TRANSLOADIT_WORKSPACE']), + credentialsWorkspaceVerified: + getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true', + credentialsAuthKeyId: getSourceValue(source, ['TRANSLOADIT_AUTH_KEY_ID']), + credentialsDescription: getSourceValue(source, ['TRANSLOADIT_AUTH_KEY_DESCRIPTION']), + credentialsLoginMethod: getSourceValue(source, ['TRANSLOADIT_LOGIN_METHOD']), + credentialsEndpoint: endpoint, + endpoint, + } +} + +export function resolveCliConfig(source: 'all' | 'login' = 'all'): ResolvedCliConfig { + if (source === 'login') { + // Keep login's key, workspace, algorithm and endpoint together, independent of project dotenv. + const saved = readEnvFile(getConfiguredCredentialsFilePath('shell')) + if (!saved?.ok) return saved === null ? {} : { loadError: saved.error } + return resolveLoginSource(saved.source) + } 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 +432,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/storageCatalog.ts b/packages/node/src/cli/storageCatalog.ts new file mode 100644 index 00000000..2b8491df --- /dev/null +++ b/packages/node/src/cli/storageCatalog.ts @@ -0,0 +1,192 @@ +import type { StoredAsset } from '../alphalib/types/storageAsset.ts' +import type { ResolvedCliConfig } from './helpers.ts' +import type { IOutputCtl } from './OutputCtl.ts' + +import { RequestError } from 'got' + +import { ApiError } from '../ApiError.ts' +import { damPathMaxCodePoints } from '../alphalib/types/storageAsset.ts' +import { Transloadit } from '../Transloadit.ts' +import { + buildMissingCredentialsMessage, + noticeCliCredentialSource, + resolveCliConfig, +} from './helpers.ts' +import { assertStorageWorkspace } from './storageReceipts.ts' + +function readDeadline(signal?: AbortSignal): AbortSignal { + return signal === undefined + ? AbortSignal.timeout(60_000) + : AbortSignal.any([signal, AbortSignal.timeout(60_000)]) +} + +/** Keeps Workspace discovery, credentials and bounded catalog reads on the same trusted origin. */ +export async function withStorageCatalog( + options: { + endpoint?: string + workspace?: string + projectWorkspace?: string + signal?: AbortSignal + }, + operation: (client: Transloadit, 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 !== '/' + ) { + throw new Error( + 'Storage endpoint must be an HTTP(S) API origin without credentials, query or fragment', + ) + } + const client = new Transloadit({ + ...config.credentials, + endpoint: endpoint.origin, + maxRetries: 0, + }) + try { + const { workspace } = await client.listStoredAssets({ + limit: 1, + signal: readDeadline(options.signal), + }) + assertStorageWorkspace(workspace, options.projectWorkspace, options.workspace) + options.signal?.throwIfAborted() + return await operation(client, workspace, endpoint.origin) + } catch (error) { + options.signal?.throwIfAborted() + if (error instanceof ApiError) { + const status = error.cause?.response?.statusCode + if (error.code === 'DAM_INVALID_REQUEST' || error.code === 'DAM_INVALID_INPUT') + throw new Error( + `${failure} received an invalid request. Check the Storage prefix and paging options.`, + { cause: error }, + ) + throw new Error( + `${failure} failed${status === undefined ? '' : ` (HTTP ${status})`}. Check the Storage API at ${endpoint.origin} and the Auth Key dam:read or dam:write scope.`, + { cause: error }, + ) + } + if ( + error instanceof RequestError || + (error instanceof Error && ['TimeoutError', 'AbortError'].includes(error.name)) + ) { + throw new Error( + `${failure} timed out or lost its connection. Check the Storage endpoint and retry.`, + { cause: error }, + ) + } + throw error + } +} + +/** Catalog prefixes allow ordinary filenames that the image renderer intentionally restricts. */ +function validateCatalogPrefix(prefix: string): void { + const segments = prefix.replace(/\/$/, '').split('/') + if ( + [...prefix].length > damPathMaxCodePoints || + new TextEncoder().encode(prefix).length > 1024 || + /[\\\p{Cc}\p{Cs}]/u.test(prefix) || + (prefix !== '' && + segments.some((segment) => segment.trim() === '' || segment === '.' || segment === '..')) + ) + throw new Error( + 'Invalid Storage prefix: use at most 512 characters (1024 UTF-8 bytes) in a relative path without empty, dot, or parent-directory segments.', + ) +} + +/** Completes every bounded page, refusing wrong-Workspace, duplicate or out-of-prefix metadata. */ +export async function listStorageAssets( + client: Transloadit, + workspace: string, + prefix: string, + signal?: AbortSignal, +): Promise { + const normalizedPrefix = prefix.normalize('NFC') + validateCatalogPrefix(normalizedPrefix) + const assets: StoredAsset[] = [] + const paths = new Set() + const cursors = new Set() + let cursor: string | undefined + do { + const page = await client.listStoredAssets({ + prefix: normalizedPrefix, + cursor, + limit: 500, + signal: readDeadline(signal), + }) + if (page.workspace !== workspace) + throw new Error('Storage changed Workspace while paging the catalog') + for (const asset of page.assets) { + try { + validateCatalogPrefix(asset.path) + if (asset.path.endsWith('/')) throw new Error('Expected a file path') + } catch (error) { + throw new Error(`Storage returned an invalid asset path: ${JSON.stringify(asset.path)}`, { + cause: error, + }) + } + if (!asset.path.startsWith(normalizedPrefix) || paths.has(asset.path)) { + throw new Error( + `Storage returned a duplicate path or one outside the requested prefix: ${JSON.stringify(asset.path)}`, + ) + } + paths.add(asset.path) + assets.push(asset) + } + if (page.next_cursor === null) break + cursor = page.next_cursor + if (!cursor || cursors.has(cursor) || page.assets.at(-1)?.path !== cursor) { + throw new Error('Storage omitted or repeated its listing cursor; results would be incomplete') + } + cursors.add(cursor) + } while (cursor !== undefined) + return assets +} + +/** 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 + ? config.authWorkspace + : await withStorageCatalog( + { endpoint: options.endpoint, signal }, + async (_client, actual) => actual, + 'Workspace verification', + undefined, + { + credentials: config.auth, + credentialsEndpoint: config.endpoint, + credentialsSource: config.authSource, + }, + ) + assertStorageWorkspace(workspace, projectWorkspace, options.workspace) + signal?.throwIfAborted() + return workspace +} 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..aafa78b9 --- /dev/null +++ b/packages/node/src/cli/storageReceipts.ts @@ -0,0 +1,307 @@ +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 { storedAssetSchema } from '../alphalib/types/storageAsset.ts' +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}$/), + apiOrigin: z.string().url().optional(), + 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().url().optional(), + urlParams: z + .record(z.string(), z.union([deliveryParameterSchema, z.array(deliveryParameterSchema)])) + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough() +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 imageReceiptSchema = storedAssetSchema.extend({ + 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, file?: string): 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 = imageReceiptSchema.safeParse(receipt) + // Legacy entries remain on disk, but require native recovery before they are usable sources. + if (!dimensions.success) return [] + const { width, height, asset_id, version_id, workspace } = dimensions.data + const name = JSON.stringify(path) + return ` ${name}: { path: ${name}; workspace: ${JSON.stringify(workspace)}; asset_id: ${JSON.stringify(asset_id)}; version_id: ${JSON.stringify(version_id)}; width: ${width}; height: ${height}; thumbhash?: string; hasAlpha?: boolean }` + }) + return [ + typesHeader, + ...(file === undefined ? [] : [`// Catalog: ${JSON.stringify(basename(file))}`]), + "import '@transloadit/viewer/next'", + '', + "declare module '@transloadit/viewer/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 + ? `invalid catalog field ${error.issues[0]?.path.join('.') || 'workspace/public/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 (!storageCatalogSchema.shape.workspace.safeParse(actual).success) + throw new Error( + `Invalid Storage Workspace ${JSON.stringify(actual)}: expected a URL-safe slug beginning with a letter or digit.`, + ) + 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.`, + ) +} + +const receiptOriginSchema = z.object({ apiOrigin: z.string().url().optional() }) + +/** Delivery hosts do not prove provenance; a matching Workspace slug is not an API identity. */ +export function assertStorageCatalogOrigin( + catalog: StorageProjectCatalog | undefined, + endpoint: string, + file: string, +): void { + const mismatch = (): Error => + new Error( + `Catalog ${JSON.stringify(file)} belongs to another API environment. Use --receipts for a separate catalog; the existing file was preserved.`, + ) + if (catalog?.apiOrigin !== undefined && new URL(catalog.apiOrigin).origin !== endpoint) + throw mismatch() + if ( + catalog !== undefined && + catalog.apiOrigin === undefined && + Object.keys(catalog.images).length === 0 && + (catalog.public.length > 0 || catalog.delivery !== undefined) + ) + throw new Error( + `Catalog ${JSON.stringify(file)} has no verified API origin. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`, + ) + for (const [path, value] of Object.entries(catalog?.images ?? {})) { + const previous = receiptOriginSchema.safeParse(value) + if ( + !previous.success || + (previous.data.apiOrigin === undefined && catalog?.apiOrigin === undefined) + ) + throw new Error( + `Catalog ${JSON.stringify(file)} has no verified API origin for ${JSON.stringify(path)}. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`, + ) + if ( + previous.data.apiOrigin !== undefined && + new URL(previous.data.apiOrigin).origin !== endpoint + ) + throw mismatch() + } +} + +/** + * 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 typesLock: 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() + // Different catalog filenames still share one ambient type-registration file. + typesLock = await open(`${typesFile}.lock`, 'wx', 0o600).catch((error: unknown) => { + throw new Error( + `Generated declarations are locked: ${typesFile}. Wait for the other catalog writer.`, + { cause: error }, + ) + }) + 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.`, + ) + if (typesInfo !== undefined) { + const existing = (await readFile(typesFile, 'utf8')).replaceAll('\r\n', '\n') + const owner = existing.split('\n')[1] + const expectedOwner = `// Catalog: ${JSON.stringify(basename(file))}` + // Upgrade older generated files only when they exactly describe this existing catalog. + if (owner !== expectedOwner && !(catalog !== undefined && existing === catalogTypes(catalog))) + throw new Error( + `Generated declarations in ${typesFile} belong to ${owner?.startsWith('// Catalog: ') ? owner.slice(12) : 'another catalog'}. Use --receipts in a separate directory; the active catalog was preserved.`, + ) + } + 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 + } + if (!storageCatalogSchema.safeParse(updated).success) + throw new Error( + 'Cannot write an invalid Storage catalog; check its Workspace, public prefixes and delivery configuration.', + ) + // 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, file), { 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 (typesLock !== undefined) { + await typesLock.close() + await rm(`${typesFile}.lock`, { force: true }) + } + 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/storageSnippets.ts b/packages/node/src/cli/storageSnippets.ts new file mode 100644 index 00000000..7d627a2f --- /dev/null +++ b/packages/node/src/cli/storageSnippets.ts @@ -0,0 +1,96 @@ +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/viewer/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 { Image } from '@transloadit/viewer/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..37f7187c --- /dev/null +++ b/packages/node/src/storageImage.ts @@ -0,0 +1,266 @@ +import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts' +import type { StoredAsset } from './alphalib/types/storageAsset.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 { storedAssetSchema } from './alphalib/types/storageAsset.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 extends Readonly { + /** Present as true only when the locally decoded original has an alpha channel. */ + readonly hasAlpha?: boolean + readonly height: number + readonly md5hash: string + /** 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}$/), + }), +}) +const completedImageSchema = z.object({ + ok: z.literal('ASSEMBLY_COMPLETED'), + results: z.object({ + ':original': z.tuple([ + storedAssetSchema.extend({ + md5hash: storedAssetSchema.shape.md5hash.unwrap(), + height: positiveIntegerSchema, + width: positiveIntegerSchema, + 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 }, + }, + ) + } + return result +} 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/src/storageResults.ts b/packages/node/src/storageResults.ts new file mode 100644 index 00000000..a197cf11 --- /dev/null +++ b/packages/node/src/storageResults.ts @@ -0,0 +1,119 @@ +import type { StoredAsset } from './alphalib/types/storageAsset.ts' +import type { Transloadit } from './Transloadit.ts' + +import { z } from 'zod' + +import { ApiError } from './ApiError.ts' +import { robotFileServeInstructionsSchema } from './alphalib/types/robots/file-serve.ts' +import { storedAssetSchema } from './alphalib/types/storageAsset.ts' +import InconsistentResponseError from './InconsistentResponseError.ts' + +/** Original-byte delivery, not a preview or transcode; authorize the application's asset first. */ +export interface StoredAssetUrlOptions { + /** True uses the receipt filename; a string supplies a different attachment filename. */ + download?: boolean | string + /** Maximum signed lifetime, from one second to 48 hours. Defaults to five minutes. */ + lifetimeMs?: number +} + +/** Signs a retained original, including non-image media, without fetching or proxying its bytes. */ +export function getStoredAssetUrl( + client: Transloadit, + input: StoredAsset, + options: StoredAssetUrlOptions = {}, +): string { + const asset = storedAssetSchema.parse(input) + const lifetimeMs = options.lifetimeMs ?? 5 * 60_000 + if (!Number.isSafeInteger(lifetimeMs) || lifetimeMs < 1000 || lifetimeMs > 48 * 3600_000) { + throw new RangeError('lifetimeMs must be an integer between 1000 and 172800000') + } + const download = options.download === true ? asset.path.split('/').at(-1) : options.download + if (download !== undefined && download !== false) { + robotFileServeInstructionsSchema.shape.download_name.unwrap().min(1).parse(download) + if (typeof download !== 'string' || /[\uD800-\uDFFF]/u.test(download)) + throw new TypeError('download must contain a valid Unicode filename') + } + // Bunny includes the full query in its cache key. Rotate at most once a minute, never at + // the lifetime boundary where rounding could issue an almost-expired grant. + const rotationMs = Math.min(60_000, Math.floor(lifetimeMs / 2)) + const expiresAt = Math.floor(Date.now() / rotationMs) * rotationMs + lifetimeMs + return client.getSignedSmartCDNUrl({ + workspace: asset.workspace, + template: 'builtin/storage-serve@0.0.3', + input: asset.asset_id, + urlParams: { v: asset.version_id, ...(typeof download === 'string' ? { download } : {}) }, + expiresAt, + }) +} + +/** Server-owned Assembly identity and expected Workspace, not values from an untrusted notification. */ +export interface GetStoredAssemblyResultsOptions { + assemblyId: string + workspace: string +} + +/** A canonical stored asset with the producing result's provenance; one input can have many outputs. */ +export interface StoredAssemblyResult { + assembly_id: string + step: string + result_id: string + original_id?: string | (string | null)[] + asset: StoredAsset +} + +const requestSchema = z.object({ + assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/), + workspace: z.string().min(1), +}) +const resultSchema = storedAssetSchema.extend({ + id: z.string().min(1), + original_id: z.union([z.string(), z.array(z.string().nullable())]).optional(), +}) + +/** + * Fetches a completed Assembly and verifies all retained results, including non-image media. + * The caller must first bind the Assembly to an authorized application upload. Register results + * idempotently by Assembly/step/result ID (or asset/version), not by filename or notification count. + */ +export async function getStoredAssemblyResults( + client: Transloadit, + options: GetStoredAssemblyResultsOptions, +): Promise { + const { assemblyId, workspace } = requestSchema.parse(options) + const assembly = await client.getAssembly(assemblyId) + if (assembly.assembly_id !== assemblyId) { + throw new InconsistentResponseError('The response did not match the requested Assembly') + } + if (typeof assembly.error === 'string') throw new ApiError({ body: assembly }) + if (assembly.ok !== 'ASSEMBLY_COMPLETED') { + throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`) + } + if (assembly.results === undefined) { + throw new InconsistentResponseError('The completed Assembly did not contain results') + } + const results: StoredAssemblyResult[] = [] + for (const [step, files] of Object.entries(assembly.results)) { + for (const file of files) { + // Ordinary temporary/exported outputs are not Storage receipts. Partial Storage records + // must fail validation rather than disappearing from an otherwise successful batch. + if ( + file.asset_id === undefined && + file.version_id === undefined && + file.workspace === undefined + ) + continue + const parsed = resultSchema.safeParse(file) + if (!parsed.success || parsed.data.workspace !== workspace) { + throw new InconsistentResponseError( + 'The Assembly returned an invalid or cross-Workspace Storage result', + { + cause: { assemblyId, step }, + }, + ) + } + const { id, original_id, ...asset } = parsed.data + results.push({ assembly_id: assemblyId, step, result_id: id, original_id, asset }) + } + } + return results +} 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/assemblies-list.test.ts b/packages/node/test/unit/cli/assemblies-list.test.ts new file mode 100644 index 00000000..8253cd1e --- /dev/null +++ b/packages/node/test/unit/cli/assemblies-list.test.ts @@ -0,0 +1,30 @@ +import { Readable } from 'node:stream' + +import { afterEach, expect, it, vi } from 'vitest' + +import { main } from '../../../src/cli.ts' +import { Transloadit } from '../../../src/Transloadit.ts' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + process.exitCode = undefined +}) + +it.each([true, false])('projects Assembly --fields in JSON=%s output', async (json) => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + vi.spyOn(Transloadit.prototype, 'streamAssemblies').mockReturnValue( + Readable.from([{ id: 'assembly-id', status: 'ASSEMBLY_COMPLETED', params: { steps: {} } }]), + ) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['assemblies', 'list', '--fields', 'id,status', ...(json ? ['--json'] : [])]) + + expect(process.exitCode).toBeUndefined() + expect(stdout).toHaveBeenCalledWith( + json + ? JSON.stringify({ id: 'assembly-id', status: 'ASSEMBLY_COMPLETED' }) + : 'assembly-id ASSEMBLY_COMPLETED', + ) +}) diff --git a/packages/node/test/unit/cli/credentials-resolution.test.ts b/packages/node/test/unit/cli/credentials-resolution.test.ts index a590a282..a1a73f27 100644 --- a/packages/node/test/unit/cli/credentials-resolution.test.ts +++ b/packages/node/test/unit/cli/credentials-resolution.test.ts @@ -1,14 +1,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { tmpdir, userInfo } from 'node:os' 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' +vi.mock('node:os', async (original) => { + const module = await original() + return { ...module, userInfo: vi.fn(module.userInfo) } +}) + const originalCwd = process.cwd() const resetExitCode = () => { @@ -59,6 +65,63 @@ function clearAmbientTransloaditEnv(): void { } describe('cli credential resolution', () => { + it('does not need a passwd entry when an explicit credentials file is configured', async () => { + const fixture = createCliFixture() + clearAmbientTransloaditEnv() + vi.stubEnv('HOME', '') + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', fixture.credentialsFilePath) + writeFileSync( + fixture.credentialsFilePath, + 'TRANSLOADIT_KEY=local-key\nTRANSLOADIT_SECRET=local-secret\n', + ) + vi.mocked(userInfo).mockImplementation(() => { + throw new Error('No passwd entry') + }) + process.chdir(fixture.cwd) + vi.spyOn(OutputCtl.prototype, 'print').mockImplementation(() => {}) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + await main(['auth', 'status']) + expect(process.exitCode).toBeUndefined() + expect(userInfo).not.toHaveBeenCalled() + } finally { + fixture.cleanup() + } + }) + 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..9d15380c --- /dev/null +++ b/packages/node/test/unit/cli/device-login.test.ts @@ -0,0 +1,587 @@ +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) + expect(JSON.parse(params).action).toBe('revoke_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..ee504690 --- /dev/null +++ b/packages/node/test/unit/cli/image-onboarding.test.ts @@ -0,0 +1,1056 @@ +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(resolveCliConfig('login').credentialsLoginMethod).toBe('stdin') + 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('logout derives revocation credentials from the same snapshot as device-key consent', async () => { + onTestFinished(() => { + vi.mocked(resolveCliConfig).mockReset() + }) + await writeFile( + 'credentials', + 'TRANSLOADIT_KEY=device-key\nTRANSLOADIT_SECRET=device-secret\nTRANSLOADIT_LOGIN_METHOD=device\n', + ) + vi.mocked(resolveCliConfig).mockReturnValue({ + credentials: { authKey: 'replacement-app-key', authSecret: 'replacement-app-secret' }, + }) + const expected = new Transloadit({ + authKey: 'device-key', + authSecret: 'device-secret', + }).calcSignature({ auth: { expires: '2030-01-01T00:00:00Z' }, steps: {} }).signature + let actual = '' + vi.spyOn(Transloadit.prototype, 'revokeOwnAuthKey').mockImplementation(function ( + this: Transloadit, + ) { + actual = this.calcSignature({ auth: { expires: '2030-01-01T00:00:00Z' }, steps: {} }).signature + return Promise.resolve() + }) + await main(['auth', 'logout']) + expect(actual).toBe(expected) +}) + +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() + const authorizer = await readFile('transloadit.authorize.ts', 'utf8') + expect(authorizer).toContain('export const authorize') + expect(authorizer).toContain('workspace, template and path') + 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/viewer/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.each([ + false, + true, + ])('example scaffolding uses an existing catalog without login or implicit publication (write-env: %s)', async (writeEnv) => { + await mkdir('app') + const catalog = { + workspace: 'my-app', + public: ['website/'], + images: { + 'website/hero.jpg': { + workspace: 'my-app', + asset_id: 'AAAAAAAAAAAAAAAAAAAAAA', + version_id: 'BBBBBBBBBBBBBBBBBBBBBA', + path: 'website/hero.jpg', + width: 800, + height: 600, + size: 123, + mime: 'image/jpeg', + }, + }, + } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + vi.mocked(resolveCliConfig).mockReturnValue({}) + await main(['image', 'init', 'website/', '--example', ...(writeEnv ? ['--write-env'] : [])]) + expect(process.exitCode).toBeUndefined() + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '@transloadit/viewer/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.*createImages/), + ) + 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) + expect(Transloadit.prototype.publishStoragePrefix).not.toHaveBeenCalled() + 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 endpoint cannot rebind an existing catalog to production', async () => { + await mkdir('app') + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + apiOrigin: 'http://127.0.0.1:3020', + public: ['website/'], + images: {}, + delivery: { + baseUrl: 'http://127.0.0.1:3020/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + }), + ) + const original = await readFile('transloadit.images.json', 'utf8') + await main([ + 'image', + 'init', + 'website/', + '--example', + '--endpoint', + 'https://api2.transloadit.com', + ]) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(original) + expect(await readdir('app')).toEqual([]) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('another API environment'), + ) + }) + + 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: { + 'website/hero.jpg': { + path: 'website/hero.jpg', + width: 800, + height: 600, + apiOrigin: catalogEndpoint ?? 'https://api2.transloadit.com', + }, + }, + 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(/API environment.*existing file was preserved/), + ) + 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', + apiOrigin: endpoint, + 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('a failed scaffold preserves a catalog updated by another Storage writer', async () => { + await mkdir('app') + const original = await vi.importActual('node:fs/promises') + onTestFinished(() => { + vi.mocked(open).mockReset() + }) + vi.mocked(open).mockImplementation(async (path, ...options) => { + if (path === 'app/storage-image-example/page.tsx') { + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + catalog.images['website/concurrent.jpg'] = { + path: 'website/concurrent.jpg', + width: 800, + height: 600, + } + await writeFile('transloadit.images.json', JSON.stringify(catalog)) + throw new Error('Cannot write example page') + } + return original.open(path, ...options) + }) + await main(['image', 'init', 'website/', '--public']) + expect(process.exitCode).toBe(1) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).images).toHaveProperty( + 'website/concurrent.jpg', + ) + expect(await readFile('transloadit-images.d.ts', 'utf8')).toContain( + 'Generated by transloadit storage', + ) + }) + + 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', + apiOrigin: 'https://api2.transloadit.com', + public: ['website/'], + images: {}, + }) + const page = await readFile(`${app}/storage-image-example/page.tsx`, 'utf8') + expect(page).toContain("from '@transloadit/viewer/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 refuses to copy a revocable device-login key into the application', async () => { + await mkdir('app') + vi.mocked(resolveCliConfig).mockReturnValue({ + ...resolveCliConfig('login'), + credentialsLoginMethod: 'device', + }) + await main(['image', 'init', 'accounts/', '--write-env', '--private']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('separate application key'), + ) + await expect(stat('.env.local')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat('transloadit.authorize.ts')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + 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, + apiOrigin: 'https://api2.transloadit.com', + }, + 'website/hero.jpg': { + path: 'website/hero.jpg', + width: 800, + height: 600, + apiOrigin: 'https://api2.transloadit.com', + }, + }, + }), + ) + 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/viewer/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,"apiOrigin":"https://api2.transloadit.com"}}}\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,"apiOrigin":"https://api2.transloadit.com"}}}\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), + apiOrigin: 'https://api2.transloadit.com', + 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-fixtures.ts b/packages/node/test/unit/cli/storage-fixtures.ts new file mode 100644 index 00000000..801003da --- /dev/null +++ b/packages/node/test/unit/cli/storage-fixtures.ts @@ -0,0 +1,30 @@ +import type { StoredAsset, StoredAssetsPage } from '../../../src/alphalib/types/storageAsset.ts' + +export function storedAsset(overrides: Partial = {}): StoredAsset { + return { + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', + path: 'website/a.jpg', + size: 123, + mime: 'image/jpeg', + width: 800, + height: 600, + md5hash: 'd41d8cd98f00b204e9800998ecf8427e', + ...overrides, + } +} + +export function storagePage( + assets: StoredAsset[] = [], + overrides: Partial = {}, +): StoredAssetsPage { + return { + ok: 'DAM_ASSETS_LISTED', + message: 'Assets listed', + workspace: 'my-app', + assets, + next_cursor: null, + ...overrides, + } +} 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..bb98eeef --- /dev/null +++ b/packages/node/test/unit/cli/storage-list.test.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm, 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 OutputCtl from '../../../src/cli/OutputCtl.ts' +import { main } from '../../../src/cli.ts' +import { storagePage, storedAsset } from './storage-fixtures.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 the available asset and version identities', async () => { + await main(['storage', 'ls', '--help']) + expect(process.stdout.write).toHaveBeenCalledWith( + expect.stringContaining('asset/version identities'), + ) +}) + +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') + .get('/dam/assets') + .query((query) => { + if (typeof query.params !== 'string') return false + expect(JSON.parse(query.params)).toMatchObject({ auth: { key: 'local-key' } }) + expect(query.signature).toBe(signParamsSync(query.params, 'local-secret')) + return true + }) + .twice() + .reply(200, storagePage()) + const unrelated = nock('http://token.invalid') + .get('/dam/assets') + .query(true) + .reply(403, { error: 'INSUFFICIENT_AUTH_SCOPE' }) + 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([ + { ...storagePage(), workspace: undefined }, + { ...storagePage(), workspace: '' }, + storagePage([storedAsset({ workspace: 'other-app' })]), +])('refuses incomplete or inconsistent workspace discovery even with an override: %j', async (body) => { + await writeFile( + 'transloadit.images.json', + JSON.stringify({ workspace: 'my-app', public: [], images: {} }), + ) + const discovery = nock('http://storage.invalid').get('/dam/assets').query(true).reply(200, body) + const listing = nock('http://storage.invalid') + .get('/dam/assets') + .query(true) + .reply(200, storagePage()) + 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.print).not.toHaveBeenCalled() +}) + +test('lists the key workspace and follows signed catalog cursors without an Assembly', async () => { + const first = storedAsset() + const second = storedAsset({ asset_id: 'C'.repeat(21) + 'A', path: 'website/b.jpg', size: 456 }) + const api = nock('http://storage.invalid') + .get('/dam/assets') + .query(true) + .reply(200, storagePage()) + .get('/dam/assets') + .query((query) => { + const params = JSON.parse(String(query.params)) + return params.prefix === 'website/' && params.limit === 500 && params.cursor === undefined + }) + .reply(200, storagePage([first], { next_cursor: first.path })) + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).cursor === first.path) + .reply(200, storagePage([second])) + 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'), [ + first, + second, + ]) +}) + +test('rejects a cursor inconsistent with its page instead of silently showing partial data', async () => { + const api = nock('http://storage.invalid') + .get('/dam/assets') + .query(true) + .reply(200, storagePage()) + .get('/dam/assets') + .query(true) + .reply(200, storagePage([storedAsset()], { next_cursor: 'wrong-path' })) + 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('/dam/assets') + .query(true) + .reply(403, { error: 'INSUFFICIENT_AUTH_SCOPE', message: 'secret-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). Check the Storage API at http://storage.invalid and the Auth Key dam:read or dam:write scope.', + ) +}) diff --git a/packages/node/test/unit/cli/storage-native.test.ts b/packages/node/test/unit/cli/storage-native.test.ts new file mode 100644 index 00000000..990a55d3 --- /dev/null +++ b/packages/node/test/unit/cli/storage-native.test.ts @@ -0,0 +1,332 @@ +import { mkdtemp, readdir, readFile, 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 streamListeners = [process.stdout, process.stderr].map((stream) => stream.listeners('error')) +const origin = 'http://storage.invalid' +const asset = { + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', + path: 'website/a.jpg', + width: 800, + height: 600, + mime: 'image/jpeg', + size: 123, +} +const page = { + ok: 'DAM_ASSETS_LISTED', + message: 'Listed', + workspace: 'my-app', + assets: [asset], + next_cursor: null, +} +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'storage-native-')) + process.chdir(directory) + await writeFile( + 'credentials', + `TRANSLOADIT_KEY=local-key\nTRANSLOADIT_SECRET=local-secret\nTRANSLOADIT_ENDPOINT=${origin}\n`, + ) + for (const name of [ + 'TRANSLOADIT_KEY', + 'TRANSLOADIT_SECRET', + 'TRANSLOADIT_AUTH_KEY', + 'TRANSLOADIT_AUTH_SECRET', + 'TRANSLOADIT_AUTH_TOKEN', + 'TRANSLOADIT_ENDPOINT', + ]) + vi.stubEnv(name, '') + vi.stubEnv('TRANSLOADIT_CREDENTIALS_FILE', join(directory, 'credentials')) + 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 [index, stream] of [process.stdout, process.stderr].entries()) + for (const listener of stream.listeners('error')) + if (!streamListeners[index]?.includes(listener)) stream.off('error', listener) + await rm(directory, { recursive: true, force: true }) +}) + +function discovery(): nock.Scope { + return nock(origin) + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).limit === 1) + .reply(200, { ...page, assets: [] }) +} + +test('ls pages the native catalog and returns identities without S3 requests', async () => { + const api = discovery() + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).prefix === 'website/') + .reply(200, { ...page, next_cursor: asset.path }) + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).cursor === asset.path) + .reply(200, { ...page, assets: [{ ...asset, path: 'website/b.jpg' }] }) + await main(['storage', 'ls', 'website/']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith(expect.any(String), [ + asset, + { ...asset, path: 'website/b.jpg' }, + ]) +}) + +test.each([ + 'ls', + 'sync', +])('%s normalizes decomposed Unicode prefixes before checking returned paths', async (command) => { + const normalized = 'café/' + const entry = { ...asset, path: `${normalized}a.jpg` } + discovery() + .get('/dam/assets') + .query(true) + .reply(200, { ...page, assets: [entry] }) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .optionally() + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main( + command === 'ls' + ? ['storage', 'ls', normalized.normalize('NFD')] + : ['storage', 'receipts', 'sync', normalized.normalize('NFD')], + ) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() +}) + +test('sync preserves an unbound production catalog instead of assuming the selected environment', async () => { + const previous = JSON.stringify({ + workspace: 'my-app', + public: ['website/'], + images: { [asset.path]: asset, 'unmatched.jpg': { ...asset, path: 'unmatched.jpg' } }, + }) + await writeFile('transloadit.images.json', previous) + discovery().get('/dam/assets').query(true).reply(200, page) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/API origin.*new.*--receipts/), + ) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) +}) + +test('sync refuses another API even before the bound catalog contains images', async () => { + const previous = JSON.stringify({ + workspace: 'my-app', + apiOrigin: 'https://api2.transloadit.com', + public: [], + images: {}, + }) + await writeFile('transloadit.images.json', previous) + discovery().get('/dam/assets').query(true).reply(200, page) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) +}) + +test('sync preserves a custom delivery host separately from verified API provenance', async () => { + const delivery = { baseUrl: 'https://images.example/file/{workspace}' } + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + public: [], + delivery, + images: { [asset.path]: { ...asset, apiOrigin: origin } }, + }), + ) + discovery().get('/dam/assets').query(true).reply(200, page) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).delivery).toEqual(delivery) +}) + +test('invalid delivery configuration names the catalog and field before making requests', async () => { + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + public: [], + delivery: { baseUrl: '/file/{workspace}' }, + images: {}, + }), + ) + discovery() + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/transloadit.images.json.*delivery.baseUrl/), + ) +}) + +test.each([ + 'website/a|b.jpg', + 'website/photo.jpg ', +])('ls preserves the catalog filename %j', async (path) => { + discovery() + .get('/dam/assets') + .query(true) + .reply(200, { ...page, assets: [{ ...asset, path }] }) + await main(['storage', 'ls', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(OutputCtl.prototype.print).toHaveBeenCalledWith(expect.any(String), [{ ...asset, path }]) +}) + +test.each([ + 'a'.repeat(513), + 'website/../private/', +])('reports an invalid prefix as input, not credential failure: %j', async (prefix) => { + discovery() + await main(['storage', 'ls', prefix]) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/Invalid Storage prefix.*512.*relative/), + ) +}) + +test('invalid Workspace discovery never writes an unreadable catalog', async () => { + nock(origin) + .get('/dam/assets') + .query(true) + .twice() + .reply(200, { ...page, workspace: '-demo', assets: [] }) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid Storage Workspace'), + ) + expect(await readdir(directory)).not.toContain('transloadit.images.json') +}) + +test('sync refuses another API environment even when its Workspace slug matches', async () => { + const previous = JSON.stringify({ + workspace: 'my-app', + public: [], + delivery: { baseUrl: 'http://other.invalid/file/{workspace}', urlParams: { cdn: 'required' } }, + images: { [asset.path]: { ...asset, apiOrigin: 'http://other.invalid' } }, + }) + await writeFile('transloadit.images.json', previous) + discovery().get('/dam/assets').query(true).reply(200, page) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/API environment.*--receipts/), + ) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) +}) + +test('sync supplies verified endpoint provenance when migrating a legacy hashed receipt', async () => { + const { asset_id: _assetId, version_id: _versionId, workspace: _workspace, ...legacy } = asset + await writeFile( + 'transloadit.images.json', + JSON.stringify({ + workspace: 'my-app', + public: [], + images: { [asset.path]: { ...legacy, apiOrigin: origin } }, + }), + ) + discovery().get('/dam/assets').query(true).reply(200, page) + nock(origin) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', public_prefixes: [] }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBeUndefined() + expect( + JSON.parse(await readFile('transloadit.images.json', 'utf8')).images[asset.path], + ).toMatchObject({ ...asset, apiOrigin: origin }) +}) + +test('receipts sync recovers pinned identities, dimensions and public policy in native pages', async () => { + const api = discovery() + .get('/dam/assets') + .query(true) + .reply(200, page) + .get('/storage/public_prefixes') + .query(true) + .reply(200, { + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [{ prefix: 'website/', created_at: '2026-09-17T00:00:00Z' }], + }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(api.isDone()).toBe(true) + const catalog = JSON.parse(await readFile('transloadit.images.json', 'utf8')) + expect(catalog).toMatchObject({ + workspace: 'my-app', + public: ['website/'], + images: { [asset.path]: asset }, + }) + expect(await readFile('transloadit-images.d.ts', 'utf8')).toContain(asset.asset_id) +}) + +test('a later page failure preserves the whole catalog and releases its lock', async () => { + const previous = JSON.stringify({ workspace: 'my-app', public: [], images: {} }) + await writeFile('transloadit.images.json', previous) + discovery() + .get('/dam/assets') + .query(true) + .reply(200, { ...page, next_cursor: asset.path }) + .get('/dam/assets') + .query(true) + .reply(503, { error: 'DAM_READ_FAILED', message: 'remote-secret' }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).not.toContain('transloadit.images.json.lock') + expect(JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls)).not.toContain( + 'remote-secret', + ) +}) 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..b7853d5e --- /dev/null +++ b/packages/node/test/unit/cli/storage-public.test.ts @@ -0,0 +1,615 @@ +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' +import { storagePage, storedAsset } from './storage-fixtures.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.each([ + 'publish', + 'unpublish', + 'init', +])('%s does not apply another environment’s policy to a same-slug catalog', async (command) => { + await mkdir('app') + const previous = JSON.stringify({ + workspace: 'my-app', + public: [], + images: { + 'website/hero.jpg': { + ...storedAsset({ path: 'website/hero.jpg' }), + apiOrigin: 'https://api2.transloadit.com', + }, + }, + }) + await writeFile('transloadit.images.json', previous) + const publish = vi + .spyOn(Transloadit.prototype, 'publishStoragePrefix') + .mockResolvedValue(declared) + const unpublish = vi + .spyOn(Transloadit.prototype, 'unpublishStoragePrefix') + .mockResolvedValue({ ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', prefix: 'website/', deleted: true }) + await main( + command === 'init' + ? ['image', 'init', 'website/', '--public', '--endpoint', origin] + : ['storage', command, 'website/'], + ) + expect(process.exitCode).toBe(1) + expect(publish).not.toHaveBeenCalled() + expect(unpublish).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/API environment.*--receipts/), + ) +}) + +test.each([ + 'publish', + 'unpublish', + 'init', +])('%s preserves an empty catalog bound to another API', async (command) => { + await mkdir('app') + const previous = JSON.stringify({ + workspace: 'my-app', + apiOrigin: 'https://api2.transloadit.com', + public: [], + images: {}, + }) + await writeFile('transloadit.images.json', previous) + const publish = vi + .spyOn(Transloadit.prototype, 'publishStoragePrefix') + .mockResolvedValue(declared) + const unpublish = vi + .spyOn(Transloadit.prototype, 'unpublishStoragePrefix') + .mockResolvedValue({ ok: 'STORAGE_PUBLIC_PREFIX_REVOKED', prefix: 'website/', deleted: true }) + await main( + command === 'init' + ? ['image', 'init', 'website/', '--public', '--endpoint', origin] + : ['storage', command, 'website/'], + ) + expect(process.exitCode).toBe(1) + expect(publish).not.toHaveBeenCalled() + expect(unpublish).not.toHaveBeenCalled() + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) +}) + +test('publishing before the first upload persists API provenance on the empty catalog', async () => { + vi.spyOn(Transloadit.prototype, 'publishStoragePrefix').mockResolvedValue(declared) + await main(['storage', 'publish', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toMatchObject({ + apiOrigin: origin, + images: {}, + }) +}) + +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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: 'my-app' })) + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).prefix === 'website/') + .reply(200, storagePage([storedAsset({ path: 'website/hero.jpg' })])) + 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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: '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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: '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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: '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', + apiOrigin: origin, + public: ['website/'], + images: {}, + delivery: { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }, + }) + expect(await readFile('app/storage-image-example/page.tsx', 'utf8')).toContain( + "from '@transloadit/viewer/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, apiOrigin: origin }, + } + 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', + apiOrigin: origin, + 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', + apiOrigin: origin, + 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/viewer/next'", + ) + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + apiOrigin: origin, + 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..c86dfd33 --- /dev/null +++ b/packages/node/test/unit/cli/storage-receipts-sync.test.ts @@ -0,0 +1,651 @@ +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 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 { updateStorageReceipts } from '../../../src/cli/storageReceipts.ts' +import { main } from '../../../src/cli.ts' +import { storagePage, storedAsset } from './storage-fixtures.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 asset = storedAsset() +const recovered = { ...asset, apiOrigin: 'http://storage.invalid' } +const policy = { + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [{ prefix: 'website/', created_at: '2026-09-14T00:00:00Z' }], +} +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]) +} + +test('an auxiliary recovery catalog cannot replace the active catalog declarations', async () => { + await updateStorageReceipts('images.json', async () => ({ + workspace: asset.workspace, + public: [], + images: { [asset.path]: recovered }, + })) + const original = await readFile('transloadit-images.d.ts', 'utf8') + const update = vi.fn(async () => ({ workspace: asset.workspace, public: [], images: {} })) + await expect(updateStorageReceipts('recovery.json', update)).rejects.toThrow( + /declarations.*images.json|images.json.*declarations/, + ) + expect(update).not.toHaveBeenCalled() + expect(await readFile('transloadit-images.d.ts', 'utf8')).toBe(original) + await expect(stat('recovery.json')).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +test('an update preserves application metadata and the JSON schema link', async () => { + const catalog = { + workspace: asset.workspace, + public: [], + images: {}, + $schema: 'https://example.test/catalog.schema.json', + application: { album: 'wedding' }, + delivery: { baseUrl: 'https://example.test', applicationNote: 'keep' }, + } + await writeFile('images.json', JSON.stringify(catalog)) + await updateStorageReceipts('images.json', async (previous) => previous) + expect(JSON.parse(await readFile('images.json', 'utf8'))).toEqual(catalog) +}) + +function storageApi(origin = 'http://storage.invalid'): nock.Scope { + nock(origin) + .get('/storage/public_prefixes') + .query((query) => { + if (typeof query.params !== 'string') return false + expect(JSON.parse(query.params)).toMatchObject({ auth: { key: 'local-key' } }) + return true + }) + .optionally() + .reply(200, policy) + return nock(origin) + .get('/dam/assets') + .query((query) => { + if (typeof query.params !== 'string') return false + expect(JSON.parse(query.params)).toMatchObject({ auth: { key: 'local-key' }, limit: 1 }) + return true + }) + .reply(200, storagePage()) +} + +function listed(assets = [asset]): nock.Scope { + return storageApi() + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).prefix === 'website/') + .reply(200, storagePage(assets)) +} + +function catalogJson(images: Record): string { + const boundImages = Object.fromEntries( + Object.entries(images).map(([path, value]) => [ + path, + typeof value === 'object' && value !== null && !Array.isArray(value) + ? { apiOrigin: recovered.apiOrigin, ...value } + : value, + ]), + ) + return `${JSON.stringify({ workspace: 'my-app', public: [], images: boundImages })}\n` +} + +test('a fresh sync recovers pinned references and the declared delivery policy', async () => { + listed() + await runSync() + expect( + process.exitCode, + JSON.stringify(vi.mocked(OutputCtl.prototype.error).mock.calls), + ).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8'))).toEqual({ + workspace: 'my-app', + apiOrigin: 'http://storage.invalid', + public: ['website/'], + images: { [asset.path]: recovered }, + delivery: { + baseUrl: 'http://storage.invalid/file/{workspace}', + urlParams: { cdn: 'required' }, + }, + }) + const types = await readFile('transloadit-images.d.ts', 'utf8') + expect(types).toContain(asset.asset_id) + expect(types).toContain(asset.version_id) +}) + +test.each([ + 'same version', + 'new version', + 'new asset', +])('local placeholders survive only the same retained version: %s', async (kind) => { + const thumbhash = '1QcSHQRnh493V4dIh4eXh1h4kJUI' + await writeFile( + 'images.json', + catalogJson({ + [asset.path]: { ...asset, thumbhash, hasAlpha: true, source: 'local-photo.png' }, + }), + ) + const current = { + ...asset, + ...(kind === 'new version' ? { version_id: 'C'.repeat(21) + 'A' } : {}), + ...(kind === 'new asset' ? { asset_id: 'D'.repeat(21) + 'A' } : {}), + } + // Matching bytes alone do not prove that this is still the same logical asset/version. + listed([current]) + await runSync() + expect(process.exitCode).toBeUndefined() + const image = JSON.parse(await readFile('images.json', 'utf8')).images[asset.path] + if (kind === 'same version') + expect(image).toEqual({ + ...current, + apiOrigin: recovered.apiOrigin, + thumbhash, + hasAlpha: true, + source: 'local-photo.png', + }) + else expect(image).toEqual({ ...current, apiOrigin: recovered.apiOrigin }) +}) + +test.each([ + true, + false, +])('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() + 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('replaces stale local policy with the server declarations, including a private workspace', async () => { + await writeFile( + 'images.json', + JSON.stringify({ + workspace: 'my-app', + apiOrigin: 'http://storage.invalid', + public: ['website/'], + images: {}, + }), + ) + nock('http://storage.invalid') + .get('/storage/public_prefixes') + .query(true) + .reply(200, { ...policy, public_prefixes: [] }) + listed() + 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', + 'body', + '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') + let stalled = false + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://localhost') + const params = JSON.parse(url.searchParams.get('params') ?? '{}') + response.setHeader('Content-Type', 'application/json') + if (stage !== 'discovery' && params.limit === 1) + return void response.end(JSON.stringify(storagePage())) + if (stage === 'policy' && url.pathname === '/dam/assets') + return void response.end(JSON.stringify(storagePage([asset]))) + if (stage === 'body') response.write('{"ok":') + 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 previous = catalogJson({ + 'other.jpg': { retained: true, apiOrigin: `http://127.0.0.1:${address.port}` }, + }) + await writeFile('images.json', previous) + 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']) + 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() + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('transloadit.images.json', 'utf8')).images[asset.path]).toEqual( + recovered, + ) + 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: { [asset.path]: { ...asset, workspace: 'other-app', source: 'private-photo.jpg' } }, + }) + await writeFile('images.json', previous) + const api = listed() + await runSync(['--workspace', 'my-app']) + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.print).toHaveBeenCalledWith( + expect.stringContaining('Catalog unchanged'), + { [asset.path]: recovered }, + ) +}) + +test('rebuilds a pinned rendering catalog from bounded pages without per-file HEAD or image GETs', async () => { + const second = storedAsset({ + asset_id: 'C'.repeat(21) + 'A', + path: 'website/b.jpg', + width: 1200, + height: 900, + size: 456, + md5hash: undefined, + }) + const api = storageApi() + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).cursor === undefined) + .reply(200, storagePage([asset], { next_cursor: asset.path })) + .get('/dam/assets') + .query((query) => JSON.parse(String(query.params)).cursor === asset.path) + .reply(200, storagePage([second])) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(api.isDone()).toBe(true) + const expected = { + [asset.path]: recovered, + [second.path]: { ...second, apiOrigin: recovered.apiOrigin }, + } + 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) => { + const setMask = process.umask + const previousMask = setMask(mask) + onTestFinished(() => { + setMask(previousMask) + }) + const maskRead = vi.spyOn(process, 'umask') + await chmod('credentials', 0o600) + const api = listed() + 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-discovery', + 'ls-body', + 'sync-discovery', + 'sync-list', + 'sync-body', +])('aborts stalled %s without replacing the catalog or holding its lock', async (operation) => { + nock.enableNetConnect('127.0.0.1') + const deadline = AbortSignal.timeout + vi.spyOn(AbortSignal, 'timeout').mockImplementation((timeout) => + deadline(timeout === 60_000 ? 500 : timeout), + ) + let stalledRequests = 0 + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://localhost') + const params = JSON.parse(url.searchParams.get('params') ?? '{}') + response.setHeader('Content-Type', 'application/json') + if (!operation.endsWith('-discovery') && params.limit === 1) + return void response.end(JSON.stringify(storagePage())) + if (operation.endsWith('-body')) response.write('{"ok":') + 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', apiOrigin: `http://127.0.0.1:${address.port}` }, + }) + await writeFile('images.json', previous) + const options = ['--endpoint', `http://127.0.0.1:${address.port}`] + 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(1) + 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('/dam/assets').query(true).reply(200, storagePage()) + 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('canonical version geometry and MIME replace stale local metadata without losing local evidence', async () => { + await writeFile( + 'images.json', + catalogJson({ + [asset.path]: { ...asset, width: 600, height: 800, mime: 'image/png', source: 'photo.jpg' }, + }), + ) + listed() + await runSync() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images[asset.path]).toEqual({ + ...recovered, + source: 'photo.jpg', + }) +}) + +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 }, + [asset.path]: { asset_id: 'old', size: 999, md5hash: 'stale' }, + } + await writeFile('images.json', catalogJson(previous)) + await chmod('images.json', 0o640) + listed() + await runSync() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images).toEqual({ + ...JSON.parse(catalogJson(previous)).images, + [asset.path]: recovered, + }) + expect((await stat('images.json')).mode & 0o777).toBe(0o640) +}) + +test.each([ + undefined, + 'a'.repeat(32), +])('retains only the canonical optional MD5, never a fabricated ETag: %s', async (md5hash) => { + listed([{ ...asset, md5hash }]) + await runSync() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images[asset.path]).toEqual({ + ...recovered, + md5hash, + }) +}) + +test.each([ + undefined, + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, +])('fails atomically for missing/invalid image dimensions %j', async (width) => { + const previous = catalogJson({ unrelated: { keep: true } }) + await writeFile('images.json', previous) + const api = listed([{ ...asset, width }]) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining('width')) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(await readdir(directory)).toEqual(['credentials', 'images.json']) +}) + +test.each([ + '', + 'loop', +])('rejects incomplete/repeated pagination before changing the file: %s', async (cursor) => { + const previous = catalogJson({ keep: { ...recovered, path: 'keep' } }) + await writeFile('images.json', previous) + const api = storageApi() + .get('/dam/assets') + .query(true) + .reply(200, storagePage([], { next_cursor: 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([{ ...asset, 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, 503, +])('sanitizes catalog HTTP %i and preserves the entire previous catalog', async (status) => { + const previous = catalogJson({ keep: { ...recovered, path: 'keep' } }) + await writeFile('images.json', previous) + const api = storageApi() + .get('/dam/assets') + .query(true) + .reply(status, { error: 'DAM_READ_FAILED', message: 'local-secret' }) + await runSync() + expect(process.exitCode).toBe(1) + expect(api.isDone()).toBe(true) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + `Storage receipt sync failed (HTTP ${status}). Check the Storage API at http://storage.invalid and the Auth Key dam:read or dam:write scope.`, + ) + expect(vi.mocked(OutputCtl.prototype.error).mock.calls.flat().join(' ')).not.toContain( + 'local-secret', + ) + expect(await readFile('images.json', 'utf8')).toBe(previous) +}) + +test('does not save an earlier image when a later image lacks height', async () => { + const previous = catalogJson({ keep: { ...recovered, path: 'keep' } }) + await writeFile('images.json', previous) + listed([asset, storedAsset({ path: 'website/b.jpg', height: undefined })]) + await runSync() + expect(process.exitCode).toBe(1) + 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 arbitrary version metadata', async () => { + const api = listed([asset, asset]) + 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: { ...recovered, path: 'keep' } }) + await writeFile('images.json', previous) + vi.mocked(rename).mockRejectedValueOnce(new Error('EACCES: rename denied')) + listed() + await runSync() + expect(process.exitCode).toBe(1) + 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: { ...recovered, path: 'keep' }, + [asset.path]: recovered, + }) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith(expect.stringContaining(temporary)) +}) 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..c19ea385 --- /dev/null +++ b/packages/node/test/unit/cli/storage-store.test.ts @@ -0,0 +1,1395 @@ +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' +import { storagePage, storedAsset } from './storage-fixtures.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 = { + ...storedAsset(), + apiOrigin: 'https://api2.transloadit.com', + 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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: '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('plain uploads record API provenance so recovery cannot mix matching Workspace slugs', async () => { + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue({ + ...storedAsset(), + path: receipt.path, + width: 800, + height: 600, + }) + await runStore() + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(await readFile('images.json', 'utf8')).images[receipt.path]).toMatchObject({ + apiOrigin: 'https://api2.transloadit.com', + }) + }) + + test('never writes a receipt from another Workspace into the verified project catalog', async () => { + await writeFile('hero.jpg', Buffer.from('image')) + const previous = catalogJson({}) + await writeFile('images.json', previous) + vi.spyOn(Transloadit.prototype, 'storeImage').mockResolvedValue({ + ...receipt, + workspace: 'other-app', + }) + await runStore() + expect(process.exitCode).toBe(1) + expect(await readFile('images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('receipt belongs to Workspace'), + ) + }) + 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(`local photo { + 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('native recovery migrates a legacy hashed receipt and enables a no-upload replay', async () => { + const bytes = Buffer.from('unchanged legacy upload') + const md5hash = createHash('md5').update(bytes).digest('hex') + const path = `website/hero.${md5hash.slice(0, 8)}.jpg` + const current = { ...receipt, path, md5hash, size: bytes.length } + await writeFile('hero.jpg', bytes) + await writeFile( + 'transloadit.images.json', + catalogJson({ [path]: { path, md5hash, size: bytes.length, width: 800, height: 600 } }), + ) + const store = vi.spyOn(Transloadit.prototype, 'storeImage') + const args = ['storage', 'store', './hero.jpg', 'website/', '--hashed'] + await main(args) + expect(process.exitCode).toBe(1) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/storage receipts sync.*same API environment/), + ) + process.exitCode = undefined + vi.spyOn(Transloadit.prototype, 'listStoredAssets').mockImplementation(async (options) => + storagePage(options?.limit === 1 ? [] : [current]), + ) + vi.spyOn(Transloadit.prototype, 'listPublicStoragePrefixes').mockResolvedValue({ + ok: 'STORAGE_PUBLIC_PREFIXES_LISTED', + public_prefixes: [], + }) + await main(['storage', 'receipts', 'sync', 'website/']) + expect(process.exitCode).toBeUndefined() + await main(args) + expect(process.exitCode).toBeUndefined() + expect(store).not.toHaveBeenCalled() + expect(OutputCtl.prototype.print).toHaveBeenLastCalledWith( + expect.stringContaining('no upload needed'), + { ...current, apiOrigin: 'https://api2.transloadit.com' }, + ) + }) + + 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.stringMatching(/API (?:environment|origin).*--receipts/), + ) + 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', + apiOrigin: receipt.apiOrigin, + 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/viewer/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(' { + 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', + apiOrigin: receipt.apiOrigin, + public: ['website/'], + images: { [receipt.path]: receipt }, + }) + const types = await readFile('transloadit-images.d.ts', 'utf8') + expect(types).toContain("declare module '@transloadit/viewer/next'") + expect(types).toContain( + `"website/hero.jpg": { path: "website/hero.jpg"; workspace: "my-app"; asset_id: "${receipt.asset_id}"; version_id: "${receipt.version_id}"; 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', + apiOrigin: receipt.apiOrigin, + 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', + apiOrigin: receipt.apiOrigin, + public: [], + images: { [receipt.path]: receipt }, + }) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Do not re-upload'), + ) + }) + + test('stores development delivery and refuses later writes from production credentials', 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') + const previous = await readFile('transloadit.images.json', 'utf8') + await main(['storage', 'store', './hero.jpg', receipt.path]) + expect(process.exitCode).toBe(1) + expect(await readFile('transloadit.images.json', 'utf8')).toBe(previous) + expect(OutputCtl.prototype.error).toHaveBeenCalledWith( + expect.stringMatching(/API environment.*--receipts/), + ) + 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( + `hero`, + ), + { ...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, + height: undefined, + 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 native Storage catalog API') + 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('/dam/assets') + .query(true) + .reply(200, storagePage([], { workspace: '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('hero\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' : 'invalid catalog field', + ) + 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 { + const boundImages = Object.fromEntries( + Object.entries(images).map(([path, value]) => [ + path, + typeof value === 'object' && value !== null && !Array.isArray(value) + ? { apiOrigin: 'https://api2.transloadit.com', ...value } + : value, + ]), + ) + return `${JSON.stringify({ workspace: 'my-app', public: [], images: boundImages })}\n` +} diff --git a/packages/node/test/unit/cli/templates-list.test.ts b/packages/node/test/unit/cli/templates-list.test.ts index 8e934ed8..ea1738bc 100644 --- a/packages/node/test/unit/cli/templates-list.test.ts +++ b/packages/node/test/unit/cli/templates-list.test.ts @@ -17,6 +17,54 @@ afterEach(() => { }) describe('cli templates list', () => { + it.each([true, false])('projects --fields in JSON=%s output', async (json) => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ + items: [ + { + id: 'template-id', + name: 'Products', + content: { steps: {} }, + require_signature_auth: 1, + }, + ], + count: 1, + }) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['templates', 'list', '--fields', 'id,name', ...(json ? ['--json'] : [])]) + + expect(process.exitCode).toBeUndefined() + const output = stdout.mock.calls.map(([chunk]) => String(chunk)).join('') + if (json) { + expect(JSON.parse(output)).toEqual({ id: 'template-id', name: 'Products' }) + } else { + expect(output.trim()).toBe('template-id Products') + } + }) + + it('keeps the complete JSON record when --fields is omitted', async () => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + const template = { + id: 'template-id', + name: 'Products', + content: { steps: {} }, + require_signature_auth: 1, + } + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ + items: [template], + count: 1, + }) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['templates', 'list', '--json']) + + expect(process.exitCode).toBeUndefined() + expect(JSON.parse(stdout.mock.calls.map(([chunk]) => String(chunk)).join(''))).toEqual(template) + }) + it('accepts --include-builtin and forwards it to the API', async () => { vi.stubEnv('TRANSLOADIT_KEY', 'key') vi.stubEnv('TRANSLOADIT_SECRET', 'secret') 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..9fbceb4a 100644 --- a/packages/node/test/unit/robots.test.ts +++ b/packages/node/test/unit/robots.test.ts @@ -1,8 +1,61 @@ import { describe, expect, it } from 'vitest' +import { stepSchema, stepSchemaWithHiddenFields } from '../../src/alphalib/types/template.ts' import { getRobotHelp, listRobots } from '../../src/Transloadit.ts' describe('robot catalog helpers', () => { + it('keeps folder recursion documented in Storage import help', () => { + const help = getRobotHelp({ robotName: '/transloadit/import', detailLevel: 'full' }) + expect(help.optionalParams.find((param) => param.name === 'recursive')?.description).toContain( + 'Whether to import files from subfolders and sub-subfolders', + ) + }) + + it.each([ + ['true', true], + ['false', false], + ])('normalizes recursive string %s for Storage path imports', (recursive, expected) => { + const step = { robot: '/transloadit/import', path: 'photos/', recursive } + expect(stepSchema.parse(step)).toMatchObject({ recursive: expected }) + expect(stepSchemaWithHiddenFields.parse(step)).toMatchObject({ recursive: expected }) + }) + + it.each([ + {}, + { path: 'photos/cat.jpg', asset_id: 'AAAAAAAAAAAAAAAAAAAAAA' }, + { version_id: 'BBBBBBBBBBBBBBBBBBBBBA' }, + { asset_id: 'AAAAAAAAAAAAAAAAAAAAAA', recursive: true }, + ])('rejects invalid Storage import selectors: %j', (selector) => { + const step = { robot: '/transloadit/import', ...selector } + expect(stepSchema.safeParse(step).success).toBe(false) + expect(stepSchemaWithHiddenFields.safeParse(step).success).toBe(false) + }) + + it('keeps nonrecursive ID imports valid after interpolation-aware parsing', () => { + const step = { robot: '/transloadit/import', asset_id: '${fields.asset}', recursive: false } + expect(stepSchema.parse(step)).toMatchObject(step) + expect(stepSchemaWithHiddenFields.parse(step)).toMatchObject(step) + }) + + 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/storageAssets.test.ts b/packages/node/test/unit/storageAssets.test.ts new file mode 100644 index 00000000..b149d5a4 --- /dev/null +++ b/packages/node/test/unit/storageAssets.test.ts @@ -0,0 +1,169 @@ +import nock from 'nock' +import { afterEach, expect, test } from 'vitest' + +import { Transloadit } from '../../src/Transloadit.ts' + +const origin = 'http://127.0.0.1:9' +const client = new Transloadit({ authKey: 'key', authSecret: 'secret', endpoint: origin }) +const asset = { + workspace: 'my-app', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', + path: 'photos/renamed.jpg', + size: 100, + mime: 'image/jpeg', + width: 800, + height: 616, +} + +afterEach(() => nock.cleanAll()) + +test('reads a pinned version without treating the saved path as its identity', async () => { + const request = nock(origin) + .get(`/dam/assets/${asset.asset_id}`) + .query((query) => { + const params = JSON.parse(String(query.params)) + return ( + params.auth.key === 'key' && + params.version_id === asset.version_id && + typeof query.signature === 'string' + ) + }) + .reply(200, { ok: 'DAM_ASSET_FOUND', message: 'Found', asset }) + await expect( + client.getStoredAsset(asset.asset_id, { version_id: asset.version_id }), + ).resolves.toEqual(asset) + expect(request.isDone()).toBe(true) +}) + +test('lists a bounded native metadata page, including Workspace context for an empty page', async () => { + const request = nock(origin) + .get('/dam/assets') + .query((query) => { + const params = JSON.parse(String(query.params)) + return params.prefix === 'photos/' && params.limit === 2 && params.cursor === 'photos/a.jpg' + }) + .reply(200, { + ok: 'DAM_ASSETS_LISTED', + message: 'Listed', + workspace: 'my-app', + assets: [], + next_cursor: null, + }) + await expect( + client.listStoredAssets({ prefix: 'photos/', limit: 2, cursor: 'photos/a.jpg' }), + ).resolves.toMatchObject({ workspace: 'my-app', assets: [], next_cursor: null }) + expect(request.isDone()).toBe(true) +}) + +test('rejects malformed identifiers and unbounded page sizes before sending', async () => { + await expect(client.getStoredAsset('../escape')).rejects.toThrow() + await expect(client.getStoredAsset(asset.asset_id, { version_id: 'wrong' })).rejects.toThrow() + await expect(client.listStoredAssets({ limit: 501 })).rejects.toThrow() +}) + +test('never silently substitutes the current version for a pinned lookup', async () => { + nock(origin) + .get(`/dam/assets/${asset.asset_id}`) + .query(true) + .reply(200, { + ok: 'DAM_ASSET_FOUND', + message: 'Found', + asset: { ...asset, version_id: 'C'.repeat(21) + 'A' }, + }) + await expect( + client.getStoredAsset(asset.asset_id, { version_id: asset.version_id }), + ).rejects.toThrow(/requested Storage reference/) +}) + +test('refuses mixed-Workspace metadata from a catalog page', async () => { + nock(origin) + .get('/dam/assets') + .query(true) + .reply(200, { + ok: 'DAM_ASSETS_LISTED', + message: 'Listed', + workspace: 'another-workspace', + assets: [asset], + next_cursor: null, + }) + await expect(client.listStoredAssets()).rejects.toThrow(/Workspace/) +}) + +test('signs original media and attachment delivery for an exact version without geometry', () => { + const video = { + ...asset, + mime: 'video/mp4', + path: 'album/été.mp4', + width: undefined, + height: undefined, + } + const inline = new URL(client.getStoredAssetUrl(video)) + expect(decodeURIComponent(inline.pathname)).toContain( + `builtin/storage-serve@0.0.3/${video.asset_id}`, + ) + expect(inline.searchParams.get('v')).toBe(video.version_id) + expect(inline.searchParams.has('download')).toBe(false) + const download = new URL(client.getStoredAssetUrl(video, { download: true })) + expect(download.searchParams.get('download')).toBe('été.mp4') + expect(download.searchParams.get('sig')).toBeTruthy() + expect(() => client.getStoredAssetUrl(video, { download: 'bad\r\nHeader: yes' })).toThrow() + expect(() => client.getStoredAssetUrl(video, { lifetimeMs: 0 })).toThrow() + expect(() => client.getStoredAssetUrl(video, { lifetimeMs: 48 * 3600_000 + 1 })).toThrow() +}) + +test('moves by identity and returns the canonical moved record without a second read', async () => { + const request = nock(origin) + .patch(`/dam/assets/${asset.asset_id}`, (body) => { + const encoded = String(body) + const params = /name="params"\r\n\r\n([^\r\n]+)/.exec(encoded)?.[1] + if (params === undefined) return false + const parsed = JSON.parse(params) + return ( + parsed.filename === 'renamed.jpg' && + parsed.destination_folder_id === null && + encoded.includes('name="signature"') + ) + }) + .reply(200, { ok: 'DAM_ASSET_MOVED', message: 'Moved', asset }) + await expect( + client.moveStoredAsset(asset.asset_id, { + filename: 'renamed.jpg', + destination_folder_id: null, + }), + ).resolves.toEqual(asset) + expect(request.isDone()).toBe(true) +}) + +test('soft-deletes by identity and returns the deletion receipt', async () => { + nock(origin).delete(`/dam/assets/${asset.asset_id}`).reply(200, { + ok: 'DAM_ASSET_DELETED', + message: 'Deleted', + asset_id: asset.asset_id, + deleted_at: '2026-09-17T12:00:00.000Z', + }) + await expect(client.deleteStoredAsset(asset.asset_id)).resolves.toEqual({ + asset_id: asset.asset_id, + deleted_at: '2026-09-17T12:00:00.000Z', + }) +}) + +test('rejects an empty move and identifiers before sending a mutation', async () => { + await expect(client.moveStoredAsset(asset.asset_id, {})).rejects.toThrow() + await expect(client.moveStoredAsset('../escape', { filename: 'photo.jpg' })).rejects.toThrow() + await expect(client.moveStoredAsset(asset.asset_id, { filename: '../escape' })).rejects.toThrow() + await expect(client.deleteStoredAsset('../escape')).rejects.toThrow() +}) + +test('refuses a mutation response for another asset', async () => { + nock(origin) + .patch(`/dam/assets/${asset.asset_id}`) + .reply(200, { + ok: 'DAM_ASSET_MOVED', + message: 'Moved', + asset: { ...asset, asset_id: 'C'.repeat(21) + 'A' }, + }) + await expect(client.moveStoredAsset(asset.asset_id, { filename: 'photo.jpg' })).rejects.toThrow( + /requested Storage reference/, + ) +}) diff --git a/packages/node/test/unit/storageImage.test.ts b/packages/node/test/unit/storageImage.test.ts new file mode 100644 index 00000000..98fcee60 --- /dev/null +++ b/packages/node/test/unit/storageImage.test.ts @@ -0,0 +1,589 @@ +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 storedIdentity = { + workspace: 'my-app', + version_id: 'B'.repeat(21) + 'A', + mime: 'image/jpeg', +} +const receipt = { + ...storedIdentity, + asset_id: 'JN6OawlqFmL419U23jUKcg', + md5hash: createHash('md5').update(bytes).digest('hex'), + meta: { height: 100, width: 100 }, + width: 100, + height: 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({ + ...storedIdentity, + 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({ + ...storedIdentity, + 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 }, + width: 80, + height: 160, + } + 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 }, + 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, width: 1200, height: 800 }] }, + }) + await expect(client.storeImage(filePath, { path: receipt.path })).resolves.toEqual({ + ...storedIdentity, + 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], +])('uses catalog display dimensions without reinterpreting EXIF %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, width, height }] }, + }) + 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, width, height }] }, + }) + 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 }], + ['missing version ID', { ...receipt, version_id: undefined }], + ['invalid version ID', { ...receipt, version_id: 'not-a-version' }], + ['missing Workspace', { ...receipt, workspace: 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 width', { ...receipt, width: undefined }], + ['zero height', { ...receipt, height: 0 }], + ['negative width', { ...receipt, width: -1 }], + ['fractional height', { ...receipt, height: 1.5 }], + ['unsafe width', { ...receipt, width: Number.MAX_SAFE_INTEGER + 1 }], + ['non-finite height', { ...receipt, 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/node/test/unit/storedAssemblyResults.test.ts b/packages/node/test/unit/storedAssemblyResults.test.ts new file mode 100644 index 00000000..7f72be50 --- /dev/null +++ b/packages/node/test/unit/storedAssemblyResults.test.ts @@ -0,0 +1,127 @@ +import { afterEach, expect, test, vi } from 'vitest' + +import { Transloadit } from '../../src/Transloadit.ts' + +const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) +const image = { + workspace: 'album', + asset_id: 'A'.repeat(22), + version_id: 'B'.repeat(21) + 'A', + path: 'photos/photo (1).jpg', + size: 100, + mime: 'image/jpeg', + width: 800, + height: 616, +} +const video = { + ...image, + path: 'videos/movie.mp4', + mime: 'video/mp4', + width: undefined, + height: undefined, +} + +afterEach(() => vi.restoreAllMocks()) + +test('verifies a batch with originals, video and a poster without guessing input relationships', async () => { + const get = vi.spyOn(client, 'getAssembly').mockResolvedValue({ + assembly_id: 'upload-1', + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [{ ...image, id: 'input-1', original_id: 'input-1' }], + encoded: [{ ...video, id: 'video-1', original_id: ['input-2', null] }], + poster: [{ ...image, id: 'poster-1', original_id: 'input-2' }], + unretained: [{ id: 'temporary', url: 'https://example.invalid/temporary.jpg' }], + }, + }) + const results = await client.getStoredAssemblyResults({ + assemblyId: 'upload-1', + workspace: 'album', + }) + expect(results).toEqual([ + { + assembly_id: 'upload-1', + step: ':original', + result_id: 'input-1', + original_id: 'input-1', + asset: image, + }, + { + assembly_id: 'upload-1', + step: 'encoded', + result_id: 'video-1', + original_id: ['input-2', null], + asset: video, + }, + { + assembly_id: 'upload-1', + step: 'poster', + result_id: 'poster-1', + original_id: 'input-2', + asset: image, + }, + ]) + expect( + await client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).toEqual(results) + expect(get).toHaveBeenCalledWith('upload-1') +}) + +test.each([ + { ...image, workspace: 'someone-else' }, + { ...image, version_id: undefined }, + { ...image, asset_id: 'bad-id' }, +])('rejects invalid or cross-Workspace stored results instead of silently omitting them', async (asset) => { + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + assembly_id: 'upload-1', + ok: 'ASSEMBLY_COMPLETED', + results: { ':original': [{ ...asset, id: 'input-1' }] }, + }) + await expect( + client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).rejects.toThrow(/Storage result/) +}) + +test('never reports partial writes in a failed Assembly as a completed batch', async () => { + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + assembly_id: 'upload-1', + error: 'TRANSLOADIT_STORE_CONFLICT', + message: 'Conflict', + results: { ':original': [{ ...image, id: 'input-1' }] }, + }) + await expect( + client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).rejects.toMatchObject({ code: 'TRANSLOADIT_STORE_CONFLICT' }) +}) + +test.each([ + 'ASSEMBLY_EXECUTING', + 'ASSEMBLY_CANCELED', +] as const)('does not register %s', async (ok) => { + vi.spyOn(client, 'getAssembly').mockResolvedValue({ assembly_id: 'upload-1', ok }) + await expect( + client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).rejects.toThrow(ok) +}) + +test('checks Assembly identity before returning any results', async () => { + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + assembly_id: 'different-upload', + ok: 'ASSEMBLY_COMPLETED', + results: {}, + }) + await expect( + client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).rejects.toThrow(/requested Assembly/) +}) + +test('does not invent provenance for malformed stored results', async () => { + vi.spyOn(client, 'getAssembly').mockResolvedValue({ + assembly_id: 'upload-1', + ok: 'ASSEMBLY_COMPLETED', + results: { stored: [image] }, + }) + await expect( + client.getStoredAssemblyResults({ assemblyId: 'upload-1', workspace: 'album' }), + ).rejects.toThrow(/Storage result/) +}) diff --git a/packages/transloadit/README.md b/packages/transloadit/README.md index 0daaa0aa..fb4c6b36 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`) @@ -51,32 +51,31 @@ This package includes a full-featured CLI for interacting with Transloadit from ### Quick Start -Pick one auth setup and then run the CLI. - -Use shell env vars: +Log in through your browser, then run a command: ```bash -export TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" -export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" +npx -y @transloadit/node auth login +npx -y @transloadit/node auth status +npx -y @transloadit/node --help ``` -Or create a reusable home credentials file: +No account yet? Choose Sign up in the browser, create your workspace, then approve the code from +your terminal. Login saves a dedicated CLI key in the owner-only `~/.transloadit/credentials` file. +Use `auth login --no-browser` to open the printed link yourself; `auth login --stdin` imports dotenv +credentials without browser approval. Existing saved credentials require `--replace`. -```bash -mkdir -p ~/.transloadit -cat > ~/.transloadit/credentials <<'EOF' -TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" -TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" -EOF -chmod 600 ~/.transloadit/credentials -``` +### CI and manual credentials -Then see all available commands: +For automation, provide an appropriately scoped key through your CI secret manager: ```bash -npx -y @transloadit/node --help +export TRANSLOADIT_KEY="YOUR_TRANSLOADIT_KEY" +export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" ``` +For application servers, provision a separate deployment key rather than copying your CLI login. +`auth logout` revokes the saved CLI key; it should not interrupt a deployed application. + The CLI binary is still called `transloadit`, so command examples below may use `npx transloadit ...`. @@ -107,6 +106,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/viewer` 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 Image 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. +Storage receipts pin the returned `asset_id` and `version_id`. The image URL selects that exact +retained version, including after rename or overwrite; `v` is a real version, not a cache-busting hash. +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 or use the signed native catalog API with `dam:read` or `dam:write`. +Older API deployments may not yet expose these reads; see [Recovery options and prerequisites](https://github.com/transloadit/node-sdk/blob/main/packages/img/docs/reference.md#recovery). + +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 +1444,140 @@ 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 `Image`. +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. + +### Read and reuse stored assets + +Save the complete stored result from `results[producingStep][i]` after `ASSEMBLY_COMPLETED`, including +its Workspace and returned path. Collision handling can rename the requested destination. +`storeImage()` returns this canonical record plus verified image metadata; other media use the +same `StoredAsset` contract without requiring image dimensions. + +```ts +const page = await client.listStoredAssets({ prefix: 'uploads/', limit: 100 }) +// Continue with { prefix: 'uploads/', cursor: page.next_cursor } while next_cursor is not null. +const current = await client.getStoredAsset(receipt.asset_id) +const pinned = await client.getStoredAsset(receipt.asset_id, { version_id: receipt.version_id }) +``` + +These signed, bounded metadata reads require `dam:read` or `dam:write`, use the key's Workspace, +and return checksums and dimensions when available. No S3 credentials or per-file HEADs are needed. + +For a multi-file or mixed-media Assembly, fetch its retained results on your server: + +```ts +// Load this upload from your database after checking the current user's access. +const outputs = await client.getStoredAssemblyResults({ + assemblyId: upload.assemblyId, + workspace: upload.workspace, +}) +for (const output of outputs) { + // Use a unique database key (assembly_id, step, result_id) so notification retries are harmless. + await registerMedia({ ...output, ownerId: upload.ownerId }) +} +``` + +Each output contains `asset` (the canonical record), `assembly_id`, `step`, `result_id`, and +`original_id` when the producing result supplies it. One input can produce an image, video and +poster; combined inputs can have an array of original IDs. Do not correlate by filename or array +position. Ordinary temporary results are omitted; malformed or cross-Workspace Storage records +fail the whole read. Failed/incomplete Assemblies are not reported as successful batches, even +if some writes already happened. This helper verifies authoritative metadata, **not application +ownership**: never use an unchecked browser/notification Assembly ID in place of `upload`. + +Rename/move with the native API to keep saved asset/version references working: + +```ts +const renamed = await client.moveStoredAsset(receipt.asset_id, { filename: 'ceremony.jpg' }) +// Supply destination_folder_id to change folders; null selects the Workspace root. +const deletion = await client.deleteStoredAsset(renamed.asset_id) +``` + +Mutations need `dam:write`. `moveStoredAsset()` returns the canonical record from the transaction +(checksums may be omitted), with the same identity and version. It never implements a move as +S3 copy/delete. A native folder move also keeps its descendants' identities. + +### Original media, playback and downloads + +`getStoredAssetUrl()` signs **exact original bytes**, not an image preview or a video transcode. +It accepts the canonical record for any media type, without requiring image dimensions: + +```ts +// In a server route, authenticate and load this exact saved reference from your own database. +const media = await loadMediaAuthorizedForUser(user, mediaId) +const url = client.getStoredAssetUrl(media.asset, { download: true }) +return new Response(null, { + status: 307, + headers: { Location: url, 'Cache-Control': 'private, no-store', Vary: 'Cookie' }, +}) +``` + +`download: true` uses the receipt's filename; a string overrides it safely. Omit `download` for +inline playback. The attachment header comes from the CDN response, including for cross-origin +links: no `fetch().blob()`, application byte proxy or whole-video memory allocation is needed. +Range requests support seeking when the original format/codecs work in that browser; generate +and store a separate compatible playback rendition when they do not. Use Viewer for previews. + +These URLs use signed `builtin/storage-serve@0.0.3` (deploy the matching API2 version first). +The signing key needs `smart_cdn:sign` or the Workspace's legacy Smart CDN-enabled key permission. +`lifetimeMs` defaults to a maximum of five minutes (allowed: one second to 48 hours). Signing +rotates at most once a minute and never more slowly than half the lifetime, so a new URL retains +at least half its requested lifetime. Bunny includes the full query in its cache key: each +rotation starts a separate cache entry. An issued URL remains usable until expiry even if the +application session is revoked. Reauthorize every redirect; do not cache private redirects in +a shared cache. A download filename changes the signed URL/cache key but never the selected bytes. + +For a later Assembly, choose exactly one `/transloadit/import` selector: + +- `{ robot: '/transloadit/import', path: receipt.path }` reads the current bytes at that location. +- `{ robot: '/transloadit/import', asset_id: receipt.asset_id }` follows the logical asset after moves. +- `{ robot: '/transloadit/import', asset_id: receipt.asset_id, version_id: receipt.version_id }` + selects the exact retained bytes. It never falls back to the current version. + +Authenticate for the saved Workspace. Deleting the asset or removing that retained version makes +the reference unavailable; an ID is neither a backup nor authorization. Use the actual stored IDs, +not a filename or digest, and keep application ownership associated with the stable asset ID. + +### 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 +1585,7 @@ import { Transloadit } from '@transloadit/node' const transloadit = new Transloadit({ authKey: 'YOUR_TRANSLOADIT_KEY', authSecret: 'YOUR_TRANSLOADIT_SECRET', + signatureAlgorithm: 'sha256', }) try { @@ -1486,10 +1685,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 +2147,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..88a283ff 100644 --- a/packages/transloadit/package.json +++ b/packages/transloadit/package.json @@ -29,7 +29,7 @@ "author": "Tim Koschuetzki ", "packageManager": "yarn@4.12.0", "engines": { - "node": ">= 20" + "node": ">= 20.10.0" }, "dependencies": { "@transloadit/sev-logger": "^0.1.9", @@ -38,6 +38,7 @@ "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 +49,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/README.md b/packages/utils/README.md index 8f452591..35e9a1f1 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -125,3 +125,7 @@ for (const source of imageCandidates.sources) { their credential boundary. - `resolveSmartCdnImageFormats(formats)` and `resolveSmartCdnImageWidths(widths, maximumWidth?)`: shared validation and normalization for adapters that use a different image Built-in. +- `getSmartCdnImageLimits(template)`: the dimension and quality bounds used by the candidate + builders. Both `builtin/public-preview@0.0.1` and the current `@0.0.2` contract are bounded at + 4096 px / quality 85; custom + Templates retain the default 8000 px / quality 100 contract. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index af56d73b..deff30d1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,10 +28,12 @@ export * from './assemblyInstructionsCompiler.ts' export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts' export { createSmartCdnImageCandidates, + getSmartCdnImageLimits, resolveSmartCdnImageFormats, 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..a07bed95 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, getSmartCdnImageLimits } from './smartCdnImage.ts' import { parseStorageGrantClaims } from './storageGrant.ts' export type { SignatureAlgorithm } from './index.ts' @@ -33,6 +33,7 @@ export type { export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts' export { + getSmartCdnImageLimits, resolveSmartCdnImageFormats, resolveSmartCdnImageWidths, smartCdnImageMaxDimension, @@ -50,6 +51,47 @@ 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 { maxDimension } = getSmartCdnImageLimits(opts.template) + 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 + ? maxDimension + : Math.max(1, Math.ceil((width * sourceDimensions.height) / sourceDimensions.width)), + }, + }) + }, + ) +} + export const signParamsSync = ( paramsString: string, authSecret: string, @@ -89,8 +131,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 +143,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..2f260f8c 100644 --- a/packages/utils/src/smartCdnImage.ts +++ b/packages/utils/src/smartCdnImage.ts @@ -3,9 +3,20 @@ const minimumMillisecondTimestamp = 1_000_000_000_000 const smartCdnImageFormats: readonly SmartCdnImageFormat[] = ['avif', 'webp', 'png'] const smartCdnImageMaxWidths = 32 -/** Maximum requested width or height accepted by the responsive-image Built-ins. */ +/** Default maximum requested width or height; the public Built-in has narrower limits. */ export const smartCdnImageMaxDimension = 8000 +/** Exact Built-in bounds shared by candidate builders and private-to-public delivery adapters. */ +export function getSmartCdnImageLimits(template: string): { + maxDimension: number + maxQuality: number +} { + // Do not assume customer Templates or future Built-in versions share this public contract. + return template === 'builtin/public-preview@0.0.1' || template === 'builtin/public-preview@0.0.2' + ? { maxDimension: 4096, maxQuality: 85 } + : { maxDimension: smartCdnImageMaxDimension, maxQuality: 100 } +} + /** Image formats supported by the responsive-image Built-in. */ export type SmartCdnImageFormat = 'avif' | 'png' | 'webp' @@ -44,20 +55,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. */ @@ -88,9 +101,9 @@ function validateSmartCdnImageDimension(value: number, name: string): void { } } -function validateSmartCdnImageQuality(quality: number): void { - if (!Number.isInteger(quality) || quality < 1 || quality > 100) { - throw new RangeError('quality must be an integer from 1 through 100') +function validateSmartCdnImageQuality(quality: number, maximum = 100): void { + if (!Number.isInteger(quality) || quality < 1 || quality > maximum) { + throw new RangeError(`quality must be an integer from 1 through ${maximum}`) } } @@ -137,21 +150,21 @@ export function resolveSmartCdnImageFormats( function getMaximumCandidateWidth( sourceDimensions: SmartCdnImageSourceDimensions | undefined, + maxDimension: number, ): number { - if (sourceDimensions === undefined) return smartCdnImageMaxDimension + if (sourceDimensions === undefined) return maxDimension validatePositiveSafeInteger(sourceDimensions.width, 'sourceDimensions.width') validatePositiveSafeInteger(sourceDimensions.height, 'sourceDimensions.height') const heightLimitedWidth = Number( - (BigInt(smartCdnImageMaxDimension) * BigInt(sourceDimensions.width)) / - BigInt(sourceDimensions.height), + (BigInt(maxDimension) * BigInt(sourceDimensions.width)) / BigInt(sourceDimensions.height), ) if (heightLimitedWidth < 1) { // Even a one-pixel-wide rendition would exceed the backend height limit; no truthful candidate // can preserve this aspect ratio. throw new RangeError('sourceDimensions aspect ratio cannot fit within backend dimensions') } - return Math.min(smartCdnImageMaxDimension, sourceDimensions.width, heightLimitedWidth) + return Math.min(maxDimension, sourceDimensions.width, heightLimitedWidth) } /** Validates, caps, deduplicates, and sorts requested responsive-image widths. */ @@ -179,9 +192,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 +210,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) @@ -207,9 +221,11 @@ export function createSmartCdnImageCandidates( if (typeof sign !== 'function') throw new TypeError('sign must be a function') const formats = resolveSmartCdnImageFormats(formatsSnapshot) + const { maxDimension, maxQuality } = getSmartCdnImageLimits(template) + for (const { quality } of formats) validateSmartCdnImageQuality(quality, maxQuality) const widths = resolveSmartCdnImageWidths( widthsSnapshot, - getMaximumCandidateWidth(sourceDimensions), + getMaximumCandidateWidth(sourceDimensions, maxDimension), ) const sources: SmartCdnImageSource[] = [] 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..c91a1207 --- /dev/null +++ b/packages/utils/test/unsignedImage.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from 'vitest' + +import { getSmartCdnImageCandidates, parseSmartCdnUrl } from '../src/node.ts' + +test.each([ + { sourceDimensions: undefined, widths: [400], expected: [[400, 4096]] }, + { + sourceDimensions: { width: 6000, height: 6000 }, + widths: [6000, 4096], + expected: [[4096, 4096]], + }, + { sourceDimensions: { width: 6000, height: 12000 }, widths: [6000], expected: [[2048, 4096]] }, +])('public candidates keep truthful descriptors within both dimension limits: $expected', ({ + sourceDimensions, + widths, + expected, +}) => { + const result = getSmartCdnImageCandidates({ + workspace: 'my-app', + template: 'builtin/public-preview@0.0.1', + input: 'website/hero.jpg', + sourceDimensions, + widths, + formats: { webp: 85 }, + fallbackUrl: '/fallback.jpg', + }) + expect( + result.sources[0]?.candidates.map(({ url, width }) => { + const query = new URL(url).searchParams + expect(Number(query.get('w'))).toBe(width) + return [width, Number(query.get('h'))] + }), + ).toEqual(expected) +}) + +test('the exact public Built-in rejects unsupported quality without restricting customer Templates', () => { + const options = { + workspace: 'my-app', + input: 'website/hero.jpg', + widths: [6000], + formats: { webp: 100 }, + fallbackUrl: '/fallback.jpg', + } + expect(() => + getSmartCdnImageCandidates({ ...options, template: 'builtin/public-preview@0.0.1' }), + ).toThrow('quality must be an integer from 1 through 85') + const custom = getSmartCdnImageCandidates({ ...options, template: 'customer-preview' }) + expect(custom.sources[0]?.quality).toBe(100) + expect(new URL(custom.sources[0]?.candidates[0]?.url ?? '').searchParams.get('h')).toBe('8000') + expect(custom.sources[0]?.candidates[0]?.width).toBe(6000) +}) + +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..ea603ea4 100644 --- a/scripts/fixtures/img-next/app/TransloaditImage.tsx +++ b/scripts/fixtures/img-next/app/TransloaditImage.tsx @@ -1,10 +1,12 @@ -import { createTransloaditImage } from '@transloadit/img/next/server' +import { createImages } from '@transloadit/viewer/next/server' -import { imageConfiguration } from './imageConfiguration.ts' +import { imageConfiguration } from './imageConfiguration' -const { Image } = createTransloaditImage({ - ...imageConfiguration, - storage: { allowedPathPrefixes: ['documents/'] }, +const { Image } = createImages({ + images: imageConfiguration.images, + baseUrl: imageConfiguration.baseUrl, + allowedPathPrefixes: ['documents/'], + delivery: 'direct', }) export { Image as TransloaditImage } diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx index bfbd58e1..f27bb06e 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 { createImages } from '@transloadit/viewer/next/server' -import { imageConfiguration } from './imageConfiguration.ts' +import { authorizeFixtureImage } from '../browser-policy' +import { imageConfiguration } from './imageConfiguration' -const { Image, storageRoute } = createTransloaditImage({ +const { Image, imageRoute } = createImages({ ...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 { Image as TransloaditRedirectImage, imageRoute } 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..d93085cb 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 { imageRoute as GET, imageRoute 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..19ebc2c1 --- /dev/null +++ b/scripts/fixtures/img-next/app/api/storage-images/route.ts @@ -0,0 +1 @@ +export { GET, HEAD } from '@transloadit/viewer/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..611e32f5 --- /dev/null +++ b/scripts/fixtures/img-next/app/art-direction/page.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..dbc31e71 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,11 @@ 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 { fixtureImage } from '../../../../storage-fixtures' +import { TransloaditImage } from '../../../TransloaditImage' +import { TransloaditRedirectImage } from '../../../TransloaditRedirectImage' interface PageProps { params: Promise<{ count: string; delivery: string }> @@ -11,9 +13,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')) { @@ -28,10 +28,19 @@ export default async function Page({ params }: PageProps): Promise { height={300} key={index} sizes="200px" - src={`documents/benchmark-${index + 1}.jpg`} + src={fixtureImage(`documents/benchmark-${index + 1}.jpg`, 400, 300)} + 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..4bb722aa --- /dev/null +++ b/scripts/fixtures/img-next/app/browser/BrowserImage.tsx @@ -0,0 +1,15 @@ +import { createImages } from '@transloadit/viewer/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 { Image: BrowserImage, imageRoute: browserStorageRoute } = createImages({ + ...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..9b79ce9c --- /dev/null +++ b/scripts/fixtures/img-next/app/browser/page.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..612e46ba --- /dev/null +++ b/scripts/fixtures/img-next/app/delivery/StorageImages.tsx @@ -0,0 +1,13 @@ +import { createImages } from '@transloadit/viewer/next/server' + +import { authorizeFixtureImage } from '../../browser-policy' +import { imageConfiguration } from '../imageConfiguration' + +export const { Image: DeliveryImage, imageRoute: deliveryRoute } = createImages({ + images: imageConfiguration.images, + 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..261673b3 --- /dev/null +++ b/scripts/fixtures/img-next/app/delivery/page.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..16a2797e --- /dev/null +++ b/scripts/fixtures/img-next/app/image-error/page.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' + +import { cookies } from 'next/headers' +import { Suspense } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..6b0fb6bc 100644 --- a/scripts/fixtures/img-next/app/imageConfiguration.ts +++ b/scripts/fixtures/img-next/app/imageConfiguration.ts @@ -1,6 +1,9 @@ +import { fixtureImages } from '../storage-fixtures' + export const imageConfiguration = { + images: fixtureImages, 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..66bb9c5e --- /dev/null +++ b/scripts/fixtures/img-next/app/layouts/page.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..149485b5 --- /dev/null +++ b/scripts/fixtures/img-next/app/package-alpha/page.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { Image } from '@transloadit/viewer/next' + +export default function Page(): ReactNode { + return ( + <> + Transparent public image + Letterboxed public image + + ) +} 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..458de36f --- /dev/null +++ b/scripts/fixtures/img-next/app/package-images/page.tsx @@ -0,0 +1,17 @@ +import { Image } from '@transloadit/viewer/next' + +export default function Page() { + return ( +
+

Images from the package

+ Package hero + Package private imageSign 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..672234c0 --- /dev/null +++ b/scripts/fixtures/img-next/app/package-public/page.tsx @@ -0,0 +1,14 @@ +import { Image } from '@transloadit/viewer/next' + +export default function Page() { + return ( + Package public hero + ) +} 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..edd4195e --- /dev/null +++ b/scripts/fixtures/img-next/app/public-image/page.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +import { createImages } from '@transloadit/viewer/next/server' + +import { imageConfiguration } from '../imageConfiguration' + +const { Image } = createImages({ + workspace: 'fixture', + images: imageConfiguration.images, + public: ['website/'], + baseUrl: imageConfiguration.baseUrl, +}) + +export default function Page(): ReactNode { + return ( +
+

Static public image

+ Public hero + Small public original +
+ ) +} diff --git a/scripts/fixtures/img-next/app/source-model/page.tsx b/scripts/fixtures/img-next/app/source-model/page.tsx new file mode 100644 index 00000000..70777f09 --- /dev/null +++ b/scripts/fixtures/img-next/app/source-model/page.tsx @@ -0,0 +1,44 @@ +import { Image } from '@transloadit/viewer/next' + +export default function Page() { + return ( +
+

Image sources

+

+ One renderer, Storage and explicit templates. Local delivery fixtures, not a production CDN + benchmark. +

+

Transloadit Storage

+ Storage experiment +

Existing HTTP origin

+ HTTP experiment +

Existing S3 bucket

+ S3 experiment +

Default workspace

+
+ Template experiment +
+
+ ) +} diff --git a/scripts/fixtures/img-next/app/source-model/types.tsx b/scripts/fixtures/img-next/app/source-model/types.tsx new file mode 100644 index 00000000..f85e4608 --- /dev/null +++ b/scripts/fixtures/img-next/app/source-model/types.tsx @@ -0,0 +1,35 @@ +import { Image } from '@transloadit/viewer/next' + +// The packed consumer compiles the real package API and its generated Storage catalog types. +const storage = Storage +const http = ( + HTTP +) +const s3 = ( + S3 +) + +// @ts-expect-error The Storage catalog still rejects misspelled paths. +const wrongCatalog = Missing +// @ts-expect-error Storage and a custom template are mutually exclusive. +const ambiguous = Ambiguous +// @ts-expect-error The package-first API needs a source choice; factories can bind a default. +const missingSource = Missing source +// @ts-expect-error External paths cannot borrow geometry from the Storage catalog. +const missingDimensions = Incomplete +// @ts-expect-error No source registry or aliases are part of the public API. +const sourceAlias = Alias + +void [storage, http, s3, wrongCatalog, ambiguous, missingSource, missingDimensions, sourceAlias] diff --git a/scripts/fixtures/img-next/app/storage-image/page.tsx b/scripts/fixtures/img-next/app/storage-image/page.tsx index 992e2b5b..d28223e6 100644 --- a/scripts/fixtures/img-next/app/storage-image/page.tsx +++ b/scripts/fixtures/img-next/app/storage-image/page.tsx @@ -1,16 +1,39 @@ import type { ReactNode } from 'react' -import { TransloaditImage } from '../TransloaditImage.tsx' +import { fixtureStorageIdentity } from '../../storage-fixtures' +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..849de0a5 --- /dev/null +++ b/scripts/fixtures/img-next/app/transparency/page.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { fixtureStorageIdentity } from '../../storage-fixtures' +import { BrowserImage } from '../browser/BrowserImage' + +const source = { + ...fixtureStorageIdentity('documents/alpha.png'), + 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..596ffa10 --- /dev/null +++ b/scripts/fixtures/img-next/browser-cdn.ts @@ -0,0 +1,176 @@ +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 { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' + +import sharp from 'sharp' + +import { imageConfiguration } from './app/imageConfiguration.ts' +import { fixtureImages } from './storage-fixtures.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 catalog: { images: typeof fixtureImages } = JSON.parse( + await readFile('transloadit.images.json', 'utf8'), + ) + const assets = new Map( + Object.values({ ...fixtureImages, ...catalog.images }).map((asset) => [asset.asset_id, asset]), + ) + 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.2/' + const privateTemplate = '/file/fixture/builtin/storage-preview@0.0.3/' + // Only these configured source adapters participate in the experiment. This is a delivery + // protocol fake with real image bytes, not evidence of HTTP or S3 import execution by API2. + const configuredSource = + path.startsWith('/file/fixture/fixture-http/website/') || + path.startsWith('/file/fixture/fixture-s3/products/') + const isPublicTemplate = path.startsWith(publicTemplate) + const asset = assets.get( + path.slice((isPublicTemplate ? publicTemplate : privateTemplate).length), + ) + const published = + isPublicTemplate && + ['website/', 'documents/public/'].some((prefix) => asset?.path.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) || configuredSource) && + authorized && + (configuredSource ? version === null : asset !== undefined && version === asset.version_id) && + 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 = asset?.path === 'documents/avatar.jpg' + const small = asset?.path === 'website/small.jpg' + const transparent = asset?.path === 'documents/alpha.png' || asset?.path === 'website/alpha.png' + 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 + ? '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..cb1b398e --- /dev/null +++ b/scripts/fixtures/img-next/browser.spec.ts @@ -0,0 +1,1283 @@ +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' +import { fixtureStorageIdentity } from './storage-fixtures.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((path) => + new URL(response.url()).pathname.endsWith(`/${fixtureStorageIdentity(path).asset_id}`), + ) + ? [ + ...(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( + fixtureStorageIdentity('documents/public/hero.jpg').version_id, + ) + 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) => + new URL(request.url).pathname.endsWith( + `/${fixtureStorageIdentity('documents/hero.jpg').asset_id}`, + ), + ) + 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) => + new URL(request.url).pathname.endsWith( + `/${fixtureStorageIdentity('documents/avatar.jpg').asset_id}`, + ), + ) + 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('package Image decodes Storage and HTTP/S3 templates without an image-byte proxy', async ({ + page, + context, + audit, +}, info) => { + await page.setViewportSize({ width: 1200, height: 1000 }) + await page.goto('/fixture/source-model') + await expect(page.getByRole('heading', { name: 'Image sources' })).toBeVisible() + await decode(page.getByRole('img', { name: 'Storage experiment', exact: true })) + await decode(page.getByRole('img', { name: 'HTTP experiment', exact: true })) + await decode(page.getByRole('img', { name: 'S3 experiment', exact: true })) + await decode(page.getByRole('img', { name: 'Template experiment', exact: true })) + const selected = await page.getByRole('img').evaluateAll((images) => + images.map((image) => { + if (!(image instanceof HTMLImageElement)) throw new Error('Expected native image') + return { + alt: image.alt, + width: image.getBoundingClientRect().width, + url: image.currentSrc, + leakedMode: + image.hasAttribute('source') || + image.hasAttribute('workspace') || + image.hasAttribute('storage') || + image.hasAttribute('template'), + } + }), + ) + expect(selected.every((image) => image.width === 320 && !image.leakedMode)).toBe(true) + // The string Template source has intrinsic 2400px geometry, but must still fit a narrow box + // without app-provided image CSS. Catalog and receipt sources use the same default. + await page.setViewportSize({ width: 280, height: 1000 }) + const mobile = await page.getByRole('img').evaluateAll((images) => + images.map((image) => ({ + width: image.getBoundingClientRect().width, + height: image.getBoundingClientRect().height, + })), + ) + expect(mobile.map((image) => image.width)).toEqual([264, 264, 264, 264]) + // Integer bitmap heights can differ slightly from the original's exact aspect ratio. + expect(mobile.map((image) => Number((image.width / image.height).toFixed(2)))).toEqual([ + 1.5, 1.5, 1.5, 1.5, + ]) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(280) + await info.attach('image-sources-mobile', { + body: await page.screenshot({ animations: 'disabled' }), + contentType: 'image/png', + }) + await page.setViewportSize({ width: 1200, height: 1000 }) + expect( + selected.map((image) => decodeURIComponent(new URL(image.url, page.url()).pathname)), + ).toEqual([ + `/file/fixture/builtin/public-preview@0.0.2/${fixtureStorageIdentity('website/hero.jpg').asset_id}`, + '/fixture/api/storage-images', + '/fixture/api/storage-images', + '/fixture/api/storage-images', + ]) + expect( + selected.slice(1).map((image) => new URL(image.url, page.url()).searchParams.get('template')), + ).toEqual(['fixture-http', 'fixture-s3', 'fixture-http']) + await expect + .poll(() => audit.images.filter((image) => new URL(image.url).origin === cdnOrigin).length) + .toBeGreaterThanOrEqual(3) + expect( + audit.images + .filter((image) => new URL(image.url).origin === cdnOrigin) + .map((image) => decodeURIComponent(new URL(image.url).pathname)), + ).toEqual( + expect.arrayContaining([ + `/file/fixture/builtin/public-preview@0.0.2/${fixtureStorageIdentity('website/hero.jpg').asset_id}`, + '/file/fixture/fixture-http/website/hero.jpg', + '/file/fixture/fixture-s3/products/hero.jpg', + ]), + ) + const privateImage = selected.find((image) => image.alt === 'HTTP experiment') + assert(privateImage) + const privateUrl = new URL(privateImage.url, page.url()) + const allowed = await page.request.get(privateUrl.href, { maxRedirects: 0 }) + expect(allowed.status()).toBe(307) + expect(allowed.headers()['cache-control']).toBe('private, no-store') + expect((await allowed.body()).length).toBe(0) + await info.attach('image-sources', { + body: await page.screenshot({ animations: 'disabled' }), + contentType: 'image/png', + }) + const wrongTemplate = new URL(privateUrl) + wrongTemplate.searchParams.set('template', 'fixture-s3') + expect((await page.request.get(wrongTemplate.href, { maxRedirects: 0 })).status()).toBe(404) + const wrongWorkspace = new URL(privateUrl) + wrongWorkspace.searchParams.set('workspace', 'other-workspace') + expect((await page.request.get(wrongWorkspace.href, { maxRedirects: 0 })).status()).toBe(404) + await context.clearCookies() + expect((await page.request.get(privateUrl.href, { maxRedirects: 0 })).status()).toBe(404) + await info.attach('source-authorization', { + body: JSON.stringify({ allowed: 307, wrongTemplate: 404, wrongWorkspace: 404, anonymous: 404 }), + contentType: 'application/json', + }) +}) + +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('[Image]')) + 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('[Image]')) + 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('[Image]')) + 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) => + new URL(image.url).pathname.endsWith( + `/${fixtureStorageIdentity('website/small.jpg').asset_id}`, + ), + ), + ) + .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.2/${fixtureStorageIdentity('website/hero.jpg').asset_id}?w=400&h=300&r=pad&f=webp&bg=%2300000000&v=${fixtureStorageIdentity('website/hero.jpg').version_id}`, + ) + 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.get(publicUrl.href)).status()).toBe(403) + publicUrl.searchParams.set('v', fixtureStorageIdentity('documents/private/hero.jpg').version_id) + expect((await context.request.get(publicUrl.href)).status()).toBe(403) + publicUrl.pathname = `/file/fixture/builtin%2Fpublic-preview%400.0.2/${fixtureStorageIdentity('documents/private/hero.jpg').asset_id}` + expect((await context.request.get(publicUrl.href)).status()).toBe(403) + publicUrl.pathname = `/file/fixture/builtin%2Fstorage-preview%400.0.3/${fixtureStorageIdentity('website/hero.jpg').asset_id}` + publicUrl.searchParams.set('v', fixtureStorageIdentity('website/hero.jpg').version_id) + 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.2/${receipt.asset_id}`, + ) + expect(url.searchParams.get('v')).toBe(receipt.version_id) + 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) => + new URL(entry.url).pathname.endsWith( + `/${fixtureStorageIdentity('website/alpha.png').asset_id}`, + ), + )?.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..03a5d30a --- /dev/null +++ b/scripts/fixtures/img-next/catalog-types.tsx @@ -0,0 +1,12 @@ +import type { RegisteredStorageImages } from '@transloadit/viewer/next' + +import { Image } from '@transloadit/viewer/next' + +const valid = Typed hero +// @ts-expect-error The generated catalog rejects misspelled paths without a consumer factory. +const invalid = Typo +// @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..2fa81d7e 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/viewer/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..507a6d8b 100644 --- a/scripts/fixtures/img-next/package-lock.json +++ b/scripts/fixtures/img-next/package-lock.json @@ -7,15 +7,20 @@ "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": { + "@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" } }, @@ -33,8 +38,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 +583,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 +613,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 +629,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 +648,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 +667,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 +686,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 +705,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 +721,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,15 +748,124 @@ "url": "https://paulmillr.com/funding/" } }, + "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": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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" + }, + "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", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "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==", + "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", @@ -775,6 +896,20 @@ "@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", @@ -787,6 +922,80 @@ "node": ">=6.0.0" } }, + "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", @@ -813,6 +1022,62 @@ "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", @@ -820,154 +1085,932 @@ "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", - "optional": true, "engines": { "node": ">=8" } }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, + "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": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "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==", + "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": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.9.19", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.23", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "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" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } + "node": ">= 0.4" } }, - "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/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/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "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", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" } }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "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.10.0" + "node": ">= 0.4" } }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "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": { - "scheduler": "^0.27.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, - "peerDependencies": { - "react": "^19.2.8" + "engines": { + "node": ">= 0.4" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "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.4", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@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", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "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", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "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", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { "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 +2028,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 +2074,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 +2113,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 +2216,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..acfa74db 100644 --- a/scripts/fixtures/img-next/package.json +++ b/scripts/fixtures/img-next/package.json @@ -7,15 +7,20 @@ }, "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": { + "@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..f99e2468 --- /dev/null +++ b/scripts/fixtures/img-next/seed.test.ts @@ -0,0 +1,488 @@ +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 { Transloadit } from '@transloadit/node' +import { createTransloaditImageModel } from '@transloadit/viewer' +import sharp from 'sharp' +import { rgbaToThumbHash } from 'thumbhash' + +import { seedStorageImage } from './seed.ts' +import { fixtureStorageIdentity } from './storage-fixtures.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 = { + ...fixtureStorageIdentity('website/photo.png'), + mime: 'image/png', + width: 1, + height: 1, + 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 }) => ({ + ...fixtureStorageIdentity(path), + mime: path.endsWith('.png') ? 'image/png' : 'image/jpeg', + 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/viewer/next'")) + assert( + declarations.includes( + `"website/hero.jpg": { path: "website/hero.jpg"; workspace: "fixture"; asset_id: "${fixtureStorageIdentity('website/hero.jpg').asset_id}"; version_id: "${fixtureStorageIdentity('website/hero.jpg').version_id}"; 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, + ...fixtureStorageIdentity(path), + path, + md5hash, + size: local.length, + 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}"; workspace: "fixture"; asset_id: "${stored.asset_id}"; version_id: "${stored.version_id}"; width: 2400; height: 1600;`, + ), + ) + assert(output.join('').includes(`hashed hero\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, + ...fixtureStorageIdentity('website/hero.jpg'), + path: 'website/hero.jpg', + size: bytes.length + 27, + md5hash: 'b'.repeat(32), + 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', + apiOrigin: 'https://api2.transloadit.com', + 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 hero', + ), + ) + assert(!printed.includes('export default function Page')) + assert(page.includes("from '@transloadit/viewer/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, + version_id: receipt.version_id, + workspace: receipt.workspace, + mime: receipt.mime, + 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.asset_id) + 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' + // API2 projects the oriented version dimensions; the SDK does not reinterpret EXIF metadata. + const response: AssemblyStatus = { + ok: 'ASSEMBLY_COMPLETED', + results: { + ':original': [ + { + ...receipt, + md5hash: createHash('md5').update(rotated).digest('hex'), + meta: { width, height, orientation: 'Rotate 90 CW' }, + width: 600, + height: 450, + 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 [ + 'workspace', + 'asset_id', + 'version_id', + 'path', + 'size', + 'mime', + 'md5hash', + 'width', + 'height', +]) { + 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/storage-fixtures.ts b/scripts/fixtures/img-next/storage-fixtures.ts new file mode 100644 index 00000000..ed7d2e40 --- /dev/null +++ b/scripts/fixtures/img-next/storage-fixtures.ts @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto' + +/** Deterministic identities for the protocol fake; API2 creates real IDs independently of paths. */ +export function fixtureStorageIdentity(path: string): { + workspace: string + asset_id: string + version_id: string +} { + const id = (value: string): string => + createHash('sha256').update(value).digest().subarray(0, 16).toString('base64url') + return { workspace: 'fixture', asset_id: id(path), version_id: id(`${path}:version-1`) } +} + +export function fixtureImage( + path: string, + width: number, + height: number, +): ReturnType & { path: string; width: number; height: number } { + return { ...fixtureStorageIdentity(path), path, width, height } +} + +export const fixtureImages = Object.fromEntries( + [ + fixtureImage('website/hero.jpg', 2400, 1600), + fixtureImage('website/small.jpg', 320, 240), + fixtureImage('website/alpha.png', 64, 64), + fixtureImage('documents/hero.jpg', 2400, 1600), + fixtureImage('documents/avatar.jpg', 400, 300), + fixtureImage('documents/late.jpg', 400, 300), + fixtureImage('documents/alpha.png', 64, 64), + fixtureImage('documents/public/hero.jpg', 400, 300), + fixtureImage('documents/private/hero.jpg', 400, 300), + fixtureImage('accounts/avatar.jpg', 400, 300), + ].map((image) => [image.path, image]), +) diff --git a/scripts/fixtures/img-next/transloadit.authorize.ts b/scripts/fixtures/img-next/transloadit.authorize.ts new file mode 100644 index 00000000..6e83d76a --- /dev/null +++ b/scripts/fixtures/img-next/transloadit.authorize.ts @@ -0,0 +1,6 @@ +import type { AuthorizeTransloaditImage } from '@transloadit/viewer/next/server' + +import { authorizeFixtureImage } from './browser-policy.ts' + +/** Toy application authorization, exercised using the browser's real HttpOnly session cookie. */ +export const authorize: AuthorizeTransloaditImage = 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..20c578af --- /dev/null +++ b/scripts/fixtures/img-next/untyped-consumer.tsx @@ -0,0 +1,5 @@ +import { Image } from '@transloadit/viewer/next' + +// Compiled in isolation without the generated declaration: runtime catalog geometry is enough. +const image = Untyped catalog source +void image diff --git a/scripts/img-next-fixture.test.ts b/scripts/img-next-fixture.test.ts index 2ed06ae2..8bcff89b 100644 --- a/scripts/img-next-fixture.test.ts +++ b/scripts/img-next-fixture.test.ts @@ -1,17 +1,473 @@ -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/viewer/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('canReadAsset(asset_id) === true') + expect(privateRecipe).toContain('// app/api/storage-images/route.ts') + expect(privateRecipe).toContain("export { GET, HEAD } from '@transloadit/viewer/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 distinguish version pinning from optional immutable filenames', async () => { + const docs = (await imageDocumentation()).replaceAll(/\s+/g, ' ') + expect(docs).toContain( + 'even a cold request after an overwrite reads that exact retained version, never 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('Hashed filenames remain useful for repository organization') + 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 { createImages } from '@transloadit/viewer/next/server'") + expect(uploads).toContain('export const { Image, imageRoute } = createImages({') + expect(uploads).toContain("allowedPathPrefixes: ['uploads/']") + expect(uploads).toContain('canReadAsset(asset_id) === true') + expect(uploads).toContain('// app/api/upload-images/route.ts') + expect(uploads).toContain("route: '/api/upload-images'") + expect(uploads).toContain( + "export { imageRoute as GET, imageRoute as HEAD } from '../../upload-images'", + ) + expect(uploads).toContain("import { Image } 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 keys and native recovery describe the version-pinned contract and deployment prerequisite', 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)') + const recovery = reference.slice(reference.indexOf('### Recovery')) + expect(recovery).toContain('storage ls') + expect(recovery).toContain('storage receipts sync') + expect(recovery).toContain('GET /dam/assets') + expect(recovery).toContain('dam:read') + expect(recovery).toContain('without S3, per-file HEAD requests') + expect(recovery).toContain('HTTP 403 means access was refused') + 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('reference.md#recovery)') + expect(node).toContain('Older API deployments may not yet expose these reads') + expect(legacy).toContain('reference.md#recovery)') + expect(reference).toContain('`v` is its actual version ID, not a digest or arbitrary cache tag') + expect(node).toContain( + 'client.getStoredAsset(receipt.asset_id, { version_id: receipt.version_id })', + ) +}) + +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 isolated workspace selection', async () => { + const documentation = await imageDocumentation() + expect(documentation).toContain('filename-derived alt') + expect(documentation).not.toContain('decorative empty alt') + expect(documentation).toContain('An explicit `workspace` prop cannot borrow another workspace') + expect(documentation).not.toContain('`TRANSLOADIT_WORKSPACE` overrides the catalog workspace') +}) + +test('the package index and reference authorizer use the selected source identity', async () => { + const root = await readFile(resolve(import.meta.dirname, '../README.md'), 'utf8') + expect(root).toContain('`@transloadit/viewer`') + expect(root).not.toContain('`@transloadit/img`') + const reference = await readFile( + resolve(import.meta.dirname, '../packages/img/docs/reference.md'), + 'utf8', + ) + const recipe = reference.slice(reference.indexOf('import { authenticate, canReadStorageObject }')) + expect(recipe).toContain('if (template !== transloaditStoragePreviewTemplate) return false') +}) + +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') + // Keep Storage, private delivery and the short HTTP/S3 entry point out of operations-manual territory. + expect(readme.split('\n').length).toBeLessThanOrEqual(110) + 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.3') + 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.2') + 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 { Image } from '@transloadit/viewer/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(110) + 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 +484,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..3880bfe6 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' @@ -7,9 +8,17 @@ import { brotliCompressSync, gzipSync } from 'node:zlib' import { execa } from 'execa' +import { fixtureStorageIdentity } from './fixtures/img-next/storage-fixtures.ts' 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 +73,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 +94,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 +102,11 @@ async function withFixtureServer( ], { cwd: fixtureDir, + env: { + ...renderingEnvironment, + IMG_FIXTURE_CDN_ORIGIN: cdnOrigin, + IMG_FIXTURE_CACHE_COMPONENTS: cacheComponents, + }, reject: false, }, ) @@ -92,12 +116,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 +168,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 +180,7 @@ function getFirstPictureCandidates(html: string): string[] { async function runImageBenchmark( baseUrl: string, + cdnOrigin: string, count: number, delivery: 'direct' | 'redirect', ): Promise { @@ -164,7 +197,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 +206,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 +232,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 +250,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 ['viewer', '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 +278,338 @@ 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.2'), + '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.2'), + '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('/' + fixtureStorageIdentity('website/hero.jpg').asset_id + '?') && + publicHtml.includes(`v=${fixtureStorageIdentity('website/hero.jpg').version_id}`), + 'Public HTML must select the receipt’s exact asset and 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.3'), + '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.3'), + '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..b1cce35c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -832,65 +832,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 @@ -1404,12 +1404,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 @@ -1420,33 +1420,6 @@ __metadata: languageName: node linkType: hard -"@transloadit/img@workspace:packages/img": - version: 0.0.0-use.local - resolution: "@transloadit/img@workspace:packages/img" - dependencies: - "@noble/ciphers": "npm:^1.3.0" - "@transloadit/utils": "workspace:^" - "@types/react": "npm:^19.2.14" - "@types/react-dom": "npm:^19.2.3" - happy-dom: "npm:^20.9.0" - next: "npm:16.3.0" - react: "npm:^19.2.6" - react-dom: "npm:^19.2.6" - server-only: "npm:^0.0.1" - peerDependencies: - next: ">=16.0.0 <17.0.0" - react: ">=19.0.0 <20.0.0" - react-dom: ">=19.0.0 <20.0.0" - peerDependenciesMeta: - next: - optional: true - react: - optional: true - react-dom: - optional: true - languageName: unknown - linkType: soft - "@transloadit/mcp-server@workspace:packages/mcp-server": version: 0.0.0-use.local resolution: "@transloadit/mcp-server@workspace:packages/mcp-server" @@ -1494,10 +1467,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 @@ -1542,6 +1520,34 @@ __metadata: languageName: unknown linkType: soft +"@transloadit/viewer@workspace:packages/img": + version: 0.0.0-use.local + resolution: "@transloadit/viewer@workspace:packages/img" + dependencies: + "@noble/ciphers": "npm:^1.3.0" + "@transloadit/utils": "workspace:^" + "@types/react": "npm:^19.2.14" + "@types/react-dom": "npm:^19.2.3" + happy-dom: "npm:^20.9.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.3.3 <17.0.0" + react: ">=19.0.0 <20.0.0" + react-dom: ">=19.0.0 <20.0.0" + peerDependenciesMeta: + next: + optional: true + react: + optional: true + react-dom: + optional: true + languageName: unknown + linkType: soft + "@transloadit/zod@npm:^4.3.1, @transloadit/zod@workspace:packages/zod": version: 0.0.0-use.local resolution: "@transloadit/zod@workspace:packages/zod" @@ -4482,24 +4488,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 +4544,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/f1bf9f608a4604348ea204cd0abd174bbe140813509168a8a4456ad9ce9f2515aff8564821dbca8234f919ba12cb295fad82c0649f73e437417cce146bb1eb39 + checksum: 10c0/e4f2f079159201c205ba373e34f9517d581947ed0cf67860d24a81073d8e388cb041b5fbc0d4a527e1523f234d14f1d3d17dee13f930ec29f2c23abb406d7508 languageName: node linkType: hard @@ -5688,7 +5694,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 +6159,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" @@ -6230,6 +6243,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,10 +6254,15 @@ __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