Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9f4b15a
Refactor node into per-operation handlers and add asset search
eitanp461 May 27, 2026
30479f7
Regenerate package-lock.json against public npm registry
eitanp461 May 28, 2026
e282690
test: unblock vitest on current toolchain
eitanp461 May 28, 2026
8f0f6cc
chore: align node descriptor, peer dep, CI, and docs
eitanp461 May 28, 2026
8d706e6
refactor: relabel resources as Asset/Library and move Search to Asset
eitanp461 May 28, 2026
55f9b1b
Add Codex to gitignore
eitanp461 May 28, 2026
8133d06
feat: add Get Asset operation (asset_id-based)
eitanp461 May 28, 2026
7d9076e
feat: add Delete Assets operation (bulk by public_id)
eitanp461 May 28, 2026
0e52a34
feat: add Update Asset Display Name operation (asset_id-based)
eitanp461 May 28, 2026
8612955
feat: add Asset resource (asset_id-based) and Append-mode tag updates
eitanp461 May 28, 2026
239e175
fix: correct codex node identifier to community package namespace
eitanp461 May 31, 2026
a40ce72
feat: surface video alongside images in node search and docs
eitanp461 May 31, 2026
d1d5107
feat: add Transform resource with 8 image/video delivery-URL operations
eitanp461 Jun 1, 2026
918ba6a
feat: add Video Player op, Multi-Step aspect-ratio crop, and thumbnai…
eitanp461 Jun 2, 2026
3c1f40c
docs: split agent guidance into lean AGENTS.md core + on-demand docs
eitanp461 Jun 2, 2026
5d3d1a1
fix: keep $json lowercase in thumbnail base-transformation description
eitanp461 Jun 2, 2026
e3d0730
fix: harden Transform delivery URLs and align release validation
eitanp461 Jun 2, 2026
4aa32d5
feat: improve agentic affordances in node and operation descriptions
eitanp461 Jun 2, 2026
7779c8a
feat: build delivery URLs via @cloudinary/url-gen with analytics slug
eitanp461 Jun 2, 2026
c96cff9
fix: make secureDistribution conditional on privateCdn in credentials
eitanp461 Jun 2, 2026
51140c2
docs: remove stale zero-dep constraint from agent instructions
eitanp461 Jun 2, 2026
af32123
fix: address PR #7 review feedback
eitanp461 Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
extraFileExtensions: ['.json'],
},

ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**'],
ignorePatterns: ['.eslintrc.js', '**/*.js', '**/node_modules/**', '**/dist/**', '**/*.test.ts', '**/testHelpers.ts'],

overrides: [
{
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: '22'
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm install -g npm@latest
- run: npm ci
- run: npm run build --if-present
- run: npm run lint
- run: npm publish
- run: npm publish --provenance
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,10 @@ vite.config.ts.timestamp-*

# IDE
.idea

# Codex
.codex

# Local scratch / temp files (not for commit)
.local/
examples/
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.19.0
v24.16.0
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# AGENTS.md

Guidance for coding agents working in this repository. This is an n8n **community node** package — a single node (`n8n-nodes-cloudinary.cloudinary`) and a single credential type (`cloudinaryApi`). No service layer, no SDK; zero runtime deps beyond the `n8n-workflow` peer dep.

Deep references live in [docs/](docs/) and are linked inline below — read them when a task touches that area, so this file stays small.

## Commands

- `npm run build` — compile TypeScript and copy SVG/PNG icons into `dist/` via gulp.
- `npm run dev` — `tsc --watch`. Does not re-run the icon copy; if icons change, re-run `build`.
- `npm run lint` / `npm run lintfix` — ESLint (`eslint-plugin-n8n-nodes-base`) over `nodes`, `credentials`, `package.json`.
- `npm run format` — Prettier over `nodes` and `credentials`.
- `npm run n8n-validate` — `@n8n/scan-community-package`; required to pass before publishing.
- `npm run prepublishOnly` — build + stricter lint (`.eslintrc.prepublish.js`); runs automatically on `npm publish`.
- `npm test` — run the Vitest suite once. `npm run test:watch` for watch mode.

## Core rules

These bite often; the linked docs carry the full reasoning.

- **No runtime dependencies — ever.** n8n forbids verified community nodes from declaring any runtime `dependencies`; `package.json` must carry only `devDependencies` and the `n8n-workflow` peer dep. This is a hard publishing gate, not a style preference — don't reach for an SDK or helper library; hand-roll it (see the pure-JS SHA-256 and multipart builders) or it won't pass verification. → [n8n verification rules](https://docs.n8n.io/integrations/community-nodes/build-community-nodes/#submit-your-node-for-verification-by-n8n)
- **Tests use Vitest, not Jest.** `*.test.ts` is excluded from `tsconfig.json`, so any shared test util importing `vitest` must also be excluded or it leaks into `dist/`. → [docs/architecture.md#testing](docs/architecture.md#testing)
- **Three interaction flows** (signed Upload API / HTTP Basic auth / delivery-URL construction with no API call) — pick the right one when adding an op. → [docs/architecture.md#three-flows](docs/architecture.md#three-flows)
- **Transformation logic lives in the component builders**, not the handlers — Multi-Step is a second consumer and must not drift. Change the builder, not a handler. → [docs/transforms.md](docs/transforms.md)
- **Mirror the Cloudinary API for field/option/output names** (`type`, `public_id`, `resource_type`, …) — don't invent or prefix when an API name exists. → [docs/conventions.md#naming-mirror-the-cloudinary-api](docs/conventions.md#naming-mirror-the-cloudinary-api)
- **Saved-workflow JSON is a public contract** — evolve additively or bump `typeVersion`; never rename a param `name` or option `value`. → [docs/backwards-compat.md](docs/backwards-compat.md)
- **Structured metadata is a pipe-separated string, not JSON** — always go through `metadataToPipeString`. → [docs/conventions.md#structured-metadata-format](docs/conventions.md#structured-metadata-format)

## Architecture at a glance

A declarative node class + a per-operation handler map + small util files:

- [Cloudinary.node.ts](nodes/Cloudinary/Cloudinary.node.ts) — `INodeTypeDescription`; fields from [descriptions/](nodes/Cloudinary/descriptions/) drive the UI via `displayOptions.show` on `resource` + `operation`. `execute()` is a thin loop: resolve creds → look up `operationHandlers[`${resource}:${operation}`]` → wrap the returned JSON into `INodeExecutionData`.
- [operations/](nodes/Cloudinary/operations/) — one file per operation, grouped by resource; each exports an `OperationHandler`. [operations/index.ts](nodes/Cloudinary/operations/index.ts) maps `${resource}:${operation}` → handler.
- [cloudinary.utils.ts](nodes/Cloudinary/cloudinary.utils.ts) — signing, multipart, URL/delivery builders, metadata serialization, error extraction.

**Adding an operation:** (1) add it to the matching `operation` options block + any fields it needs (with the right `displayOptions.show`) under [descriptions/](nodes/Cloudinary/descriptions/); (2) drop a handler file in `operations/<resource>/`; (3) register it in [operations/index.ts](nodes/Cloudinary/operations/index.ts). No change to `execute()` needed.

The full file map, the three interaction flows, the testing setup, n8n integration points, and the build-output contract are in **[docs/architecture.md](docs/architecture.md)**.
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CLAUDE.md

Project guidance for coding agents lives in [AGENTS.md](AGENTS.md) (the cross-agent standard). The line below imports it so Claude Code loads it as memory; keep all content in `AGENTS.md` and the `docs/` it links.

@AGENTS.md
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ On this page, you'll find a list of operations the Cloudinary node supports.
* Update asset metadata fields
* Get tags
* Get structured metadata definitions
* Search assets
Comment thread
eitanp461 marked this conversation as resolved.

### Search assets

Uses the [Cloudinary Search API](https://cloudinary.com/documentation/search_api) to find assets by tag, folder, metadata, or any supported expression (e.g. `tags=cat AND uploaded_at>1d`, `folder:products/*`, `tags="back to school"`).

The node emits **one n8n item per matching asset**, so downstream nodes can map over results directly without a Split Out step.

- **Resource Types** — Cloudinary's Search API defaults to image-only when no `resource_type:` clause is in the expression. This node injects the clause automatically based on the Resource Types you select (defaults to `image`). To search across all types, select all three. If your expression already contains a `resource_type:` clause, the selection is ignored.
- **Return All** — when enabled, the node automatically paginates through Cloudinary's `next_cursor` until all matching assets have been returned. When disabled, only the first page (up to *Max Results*, capped at 500) is returned.
- **Rate limits** — the node surfaces `429`/`420` responses with a clear "rate limit exceeded" error including the server's `Retry-After`.
- **Eventual consistency** — newly uploaded assets may take a few seconds to appear in search results. Avoid searching for something you just uploaded in the same workflow without a delay.

## Supported authentication methods

Expand All @@ -34,6 +46,10 @@ If you're a user with a Master admin, Admin, or Technical admin role, you can fi
4. From the top of the page copy the **Cloud name**.
5. Enter the cloud name, api key and api secret to your n8n credential.

#### Private CDN / custom delivery hostname (advanced)

Most users can skip this. If your organization is on a **Advanced plan** that uses a [private CDN distribution or a custom delivery hostname (CNAME)](https://cloudinary.com/documentation/advanced_url_delivery_options#private_cdns_and_custom_delivery_hostnames_cnames), enable **Private CDN** in the credential and enter your **Custom Delivery Hostname** so the node builds delivery URLs against your private distribution instead of the default `res.cloudinary.com`. Leave these off if you're unsure — they don't apply to standard accounts.


## Related resources

Expand Down
18 changes: 18 additions & 0 deletions credentials/CloudinaryApi.credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ export class CloudinaryApi implements ICredentialType {
default: '',
typeOptions: { password: true },
},
{
displayName: 'Private CDN',
name: 'privateCdn',
type: 'boolean',
default: false,
description:
'Whether your account delivers from a private CDN distribution (<cloud>-res.cloudinary.com). Only affects the delivery URLs built by Transform operations.',
},
{
displayName: 'Custom Delivery Hostname',
name: 'secureDistribution',
type: 'string',
default: '',
placeholder: 'assets.example.com',
description:
'Custom delivery hostname (CNAME) for your private CDN account. Leave empty to use the default <cloud>-res.cloudinary.com subdomain.',
displayOptions: { show: { privateCdn: [true] } },
},
];

// This tells how this credential is authenticated
Expand Down
40 changes: 40 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Architecture

This is an n8n **community node** package with a single node and a single credential type. There's no service layer and no SDK dependency. The runtime is a declarative node class, a per-operation handler map, and small util files.

## File map

- [nodes/Cloudinary/Cloudinary.node.ts](../nodes/Cloudinary/Cloudinary.node.ts) — declares the node via `INodeTypeDescription` (properties come from [descriptions/](../nodes/Cloudinary/descriptions/) and drive the entire n8n UI through `displayOptions.show` conditionals on `resource` + `operation`). `execute()` is a thin loop over input items: it resolves credentials, looks up `operationHandlers[`${resource}:${operation}`]`, calls the handler, and wraps each returned JSON object into an `INodeExecutionData` with the right `pairedItem`.
- [nodes/Cloudinary/operations/](../nodes/Cloudinary/operations/) — one file per operation, grouped by resource (`upload/`, `updateAsset/`, `asset/`, `admin/`, `transform/`, `widget/`), each exporting an `OperationHandler` (`(ctx, i, creds) => Promise<IDataObject[]>` — see [operations/types.ts](../nodes/Cloudinary/operations/types.ts)). [operations/index.ts](../nodes/Cloudinary/operations/index.ts) maps `${resource}:${operation}` to its handler. **Adding an operation:** (1) add it to the matching `operation` options block and any fields it needs (with the right `displayOptions.show`) under [descriptions/](../nodes/Cloudinary/descriptions/), (2) drop a handler file in `operations/<resource>/`, (3) register it in [operations/index.ts](../nodes/Cloudinary/operations/index.ts). No change to `execute()` is needed.
- [nodes/Cloudinary/cloudinary.utils.ts](../nodes/Cloudinary/cloudinary.utils.ts) — shared helpers used by the handlers: `generateCloudinarySignature`, `createMultipartBody` (hand-rolled multipart so the package has no `form-data` dependency), the URL builders (`buildUploadUrl`, `buildResourceUpdateUrl`), the delivery-URL builders (`buildDeliveryUrl`, `joinTransformation`), `metadataToPipeString`, `buildSearchExpression`, and `extractCloudinaryError`. Signature scheme and delivery-URL details are in **Three flows** below.
- [nodes/Cloudinary/sha256.utils.ts](../nodes/Cloudinary/sha256.utils.ts) — pure-JS SHA-256 so the package has zero runtime deps beyond the `n8n-workflow` peer dep.
- [credentials/CloudinaryApi.credentials.ts](../credentials/CloudinaryApi.credentials.ts) — `cloudName` / `apiKey` / `apiSecret`, plus optional `privateCdn` / `secureDistribution` (custom delivery hostname/CNAME) consumed only by `buildDeliveryUrl`. Referenced by name `cloudinaryApi` from `Cloudinary.CREDENTIAL_TYPE`.

## Three flows

The node mixes three Cloudinary interaction styles depending on endpoint — keep this distinction when adding operations:

1. **Signed Upload API** (`/v1_1/{cloud}/{resource_type}/upload`) — used by `upload.uploadUrl` and `upload.uploadFile`. Build a params object, call `generateCloudinarySignature`, append `signature`, then POST as `application/x-www-form-urlencoded` (URL upload) or `multipart/form-data` via `createMultipartBody` (file upload). The `file` field and `api_key` must be excluded from the signature payload.
2. **HTTP Basic auth** (`api_key:api_secret`) — used by everything under Admin API and Update Asset (`/tags/...`, `/metadata_fields`, `/resources/{type}/{storage_type}/{public_id}`). Passed via the `auth` option on `IHttpRequestOptions`; no signature.
3. **Delivery-URL construction (no API call)** — used by every `transform.*` operation. The handler reads fields, composes a transformation string (`joinTransformation`), and returns a delivery URL built by `buildDeliveryUrl` — it makes **no** `httpRequestWithAuthentication` call. The host is not always `res.cloudinary.com`: `buildDeliveryUrl` resolves shared (cloud in path) / private-CDN (`<cloud>-res.cloudinary.com`) / custom-hostname-CNAME (cloud absent) from the optional `privateCdn` / `secureDistribution` credentials, mirroring the SDKs' explicit-config approach. Signed delivery (`s--SIG--`, for private/authenticated/strict assets) is intentionally **not** implemented yet — the Delivery Type field carries a non-prominent disclaimer. Because these handlers make no HTTP call, their tests assert the **returned JSON** (`secure_url` + `transformation`), not a request spy — `makeCtx` already supplies the only surface they touch (`getNodeParameter` + `getNode`). Cloudinary builds delivery paths as `<public_id>.<format>` with public_id opaque, so a public_id that carries its own extension (e.g. `my_image1234.png`) needs the format re-appended (`…png.png`) or Cloudinary reads the id's extension as the format and 404s; named ops carry no format, so `trailingMediaFormat` (in `operations/transform/shared.ts`) recovers it from the public_id's trailing media extension. Signed/authenticated source assets still can't be transformed (signing not implemented).

## Testing

Tests use **Vitest** (not Jest, despite the n8n ecosystem leaning Jest). Two layers:

- **Pure-function unit tests** over the helpers in [cloudinary.utils.ts](../nodes/Cloudinary/cloudinary.utils.ts) — signing, URL builders, error extraction, metadata serialization.
- **Operation-handler tests** that mock `IExecuteFunctions` and assert the *request contract* (URL, method, `auth` vs `signature`, body shape) handed to `httpRequestWithAuthentication` — no real network calls. Shared mock builder and request-extraction helpers live in [nodes/Cloudinary/operations/testHelpers.ts](../nodes/Cloudinary/operations/testHelpers.ts). Note: because handlers call the HTTP helper via `.call(ctx, TYPE, options)`, the spy records args as `[TYPE, options]` — `this` is not in the args array.

[vitest.config.ts](../vitest.config.ts) aliases `n8n-workflow` to its built CJS entry (`n8n-workflow/dist/index.js`). This is required, not a hack: the package's `import` condition points at raw `src/index.ts`, which Vitest can't load.

`tsconfig.json` excludes `**/*.test.ts` so test files never compile into `dist/`. **Any shared test utility that imports `vitest` must also be excluded** — either name it `*.test.ts` or add it to the exclude list (as `testHelpers.ts` is). Otherwise it leaks `vitest` into the published package.

## n8n integration points

- All HTTP goes through `this.helpers.httpRequestWithAuthentication.call(this, Cloudinary.CREDENTIAL_TYPE, options)` — do not use `fetch` or `axios`.
- File uploads read binary via `this.helpers.assertBinaryData` + `getBinaryDataBuffer`; the `file` parameter holds the *binary property name*, not the file itself.
- Error handling respects `this.continueOnFail()` — per-item errors are pushed as `{ error: error.message }` rather than thrown when enabled.

## Build output contract

`package.json`'s `n8n.nodes` and `n8n.credentials` point at `dist/...` paths. The icon copy in [gulpfile.js](../gulpfile.js) is required because `tsc` won't copy the `.svg`; without it the node loads without an icon in n8n.
23 changes: 23 additions & 0 deletions docs/backwards-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Backwards compatibility (n8n workflow persistence)

n8n persists each saved workflow as JSON. That JSON references this node — and many things *inside* this node — by string identifier. Anything it references by string is a public API; anything it uses to interpret missing or stored values is part of the same contract. Both can only evolve **additively**, or behind an explicit `typeVersion` bump.

**Frozen-by-string** — renaming or removing orphans every saved workflow that references it:
- Node `type` (`n8n-nodes-cloudinary.cloudinary`) — class export and top-level `name`.
- Credential `name` (`cloudinaryApi`, exposed via `CREDENTIAL_TYPE`).
- Parameter `name` (every entry under `properties`, at every nesting level — `collection` / `fixedCollection` children included).
- Option `value` in `options` / `multiOptions` — including the `resource` and `operation` selectors, whose `value` strings key the `operationHandlers` map. Renaming `upload:uploadFile` is the same severity of break as renaming a parameter `name`.

**Frozen-by-meaning** — silent behavior change in saved workflows, no error:
- Parameter `type` (`string` → `number`, `options` → `multiOptions`, `collection` → `fixedCollection`, etc.) — mis-deserializes the stored value.
- `default` — workflows saved before a field existed (or where the user left it untouched) fall back to it on load; changing it retroactively rewrites their behavior.
- The set of `options[].value` — adding entries is safe; removing or renaming one orphans workflows that selected it.
- `displayOptions.show` — *loosening* (showing the field in more cases) is safe; *narrowing* silently drops user intent, since the stored value lingers in the JSON but stops being read.

**Free to change** — UI-only metadata: `displayName`, `description`, `placeholder`, `hint`, `group`, `icon`, ordering, help text.

**Additive-mode pattern (preferred).** Add a mode selector (`options`) whose default reproduces current behavior, gate existing fields behind that default, gate new fields behind the new mode, and add a *new* helper for the new path rather than changing the existing one. Worked example — supporting Update Asset by **asset ID** alongside the existing `resourceType` + `type` + `publicId`: the endpoint differs in method as well as path (`PUT /resources/:asset_id` vs `POST /resources/:resource_type/:type/:public_id`), so add `buildResourceUpdateByAssetIdUrl`, an "Identify By" selector defaulting to `Public ID`, an `assetId` field gated on the new mode, and branch the handler. Old workflows (no stored value) get the default and behave exactly as before — no version bump needed.

**When additive isn't possible, bump `typeVersion`.** Increment `version` in `INodeTypeDescription`, keep the old behavior reachable, and branch on `this.getNode().typeVersion` where semantics diverge. Use this for type changes, removed operations, or any repurposed field.

**Separate axis — runtime-host compatibility.** `engines.node`, the peer range on `n8n-workflow`, and any native deps govern which n8n installations can *load* this node. That's distinct from workflow-JSON compatibility (which saved workflows still *run* correctly inside a given install). Same discipline — don't silently raise the floor — but it lives in `package.json`, not the node description.
Loading
Loading