feat: per-operation refactor + Asset, Transform, and Admin operations#7
Merged
Conversation
Restructure the Cloudinary node from a single monolithic execute() into a
declarative handler map: one file per operation grouped by resource, with
field definitions split into dedicated description modules. execute() is now
a thin dispatch loop over input items.
Add an Admin "Search Assets" operation that emits one item per matching
asset, auto-injects a resource_type clause from the selected types, supports
automatic pagination ("Return All"), and surfaces rate-limit and invalid
expression errors with actionable messages.
Harden structured-metadata serialization: multi-value fields render as a
bracketed list of quoted strings and delimiter characters are escaped in
every value, matching Cloudinary's documented metadata format. Sanitize the
multipart upload filename so it can't break request framing.
Introduce a Vitest test suite covering the metadata/signature/URL helpers
and the per-operation request contracts, and share common request headers
and auth across handlers.
- vitest.config.ts: update n8n-workflow alias from dist/index.js to dist/cjs/index.js. n8n-workflow@2.16 reorganised its build output and no longer ships a top-level dist/index.js, so the previous alias resolved to nothing and every test file failed at import time with "Cannot find package 'n8n-workflow'". - .nvmrc: bump from v22.11.0 to v24.16.0 (current Active LTS, Krypton). Vitest 4 transitively pulls in std-env@4 (ESM-only) and require()s it from its CJS config loader; unflagged require(ESM) needs Node >=20.19 or >=22.12, so v22.11.0 hit ERR_REQUIRE_ESM on `vitest run`. Moving to 24.16.0 also matches engines.node already declared in package.json. All 86 tests pass after these changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Cloudinary.node.ts: switch inputs/outputs to the 'main' string literals and drop the NodeConnectionType import. Change group from the bogus ['Cloudinary'] to ['transform'] so the node lands in the right category in the n8n node picker. - package.json: pin the n8n-workflow peer range to ^2.0.0 instead of '*'. The previous wildcard let any host install try to load this node against incompatible majors; we already build and test against the 2.x API. - package-lock.json: regenerated to match. - .github/workflows/release.yml: drive setup-node from .nvmrc instead of hardcoding '22', and enable npm cache so releases install faster and stay in lockstep with the local toolchain. - CLAUDE.md: rewrite the backwards-compatibility section to spell out the frozen-by-string vs frozen-by-meaning vs free-to-change axes, call out option values and displayOptions narrowing as breaking, document the typeVersion escape hatch, and separate workflow-JSON compatibility from runtime-host compatibility. Minor tightening elsewhere. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 1 of the asset-crud reorganization. UI-only relabel — `value` strings (`updateAsset`, `admin`) are frozen-by-string per CLAUDE.md so saved workflows still resolve. `Search Assets` belongs with the entity it operates on; moved its operation entry, handler-map key, and field `displayOptions.show.resource` from `admin` to `updateAsset`. Search is not yet released, so no compatibility shim is needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the first new operation in the Asset CRUD effort. Identifies the asset by its immutable asset_id and hits `GET /resources/:asset_id` — a single-field form instead of the legacy three-field (resource_type, type, public_id) shape. Scope notes: - Deliberately asset_id-only. No `identifyBy` mode selector — for new ops, a one-field form is strictly cleaner UX than gating it behind a picker most users wouldn't need. - Existing `updateTags` / `updateMetadata` are not modified; their public_id surface stays exactly as it shipped. asset_id support for those ops is the job of the upcoming `asset` resource (see plan). Adds `buildResourceByAssetIdUrl` utility, the `getOptions` collection (colors, faces, image_metadata, pages, phash, coordinates, accessibility_analysis, derived_next_cursor), and per-handler + URL-builder tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an Asset → Delete Assets op backed by the documented public_id
endpoint:
DELETE /v1_1/{cloud}/resources/{resource_type}/{type}?public_ids=...
Cloudinary's delete endpoint is public_id-based, not asset_id-based —
an earlier draft assumed otherwise and failed at runtime with
"Illegal ids given". The field accepts:
- a single public_id (no commas needed),
- a comma-separated list, or
- an array from an n8n expression (e.g. `{{ $items().map(...) }}`).
Arrays are pre-joined to a CSV before being placed in `qs` because
n8n's default query serializer turns JS arrays into `public_ids[0]=...`
(bracketed indices), which Cloudinary rejects with "public_ids must be
a list of strings or a comma separated string". Joining to CSV
side-steps the serializer entirely.
New helpers in cloudinary.utils.ts:
- buildResourceDeleteUrl(cloud, resourceType, type)
- splitCsvIds(csv) — trims and drops blanks
Adds 8 handler tests covering URL/method, single-string, CSV trimming,
array-from-expression, non-image resource routing, options merging,
and empty-input guards, plus unit tests for the new utils.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the existing Update Asset surface into two resources: - Asset: asset_id-keyed Get/Update/Search/Delete plus tag and metadata operations. asset_id is a single global key, so callers do not need to juggle (resource_type, type) coordinates. - Asset (Legacy): kept at the bottom of the dropdown; only the public_id-keyed operations that shipped on master are exposed. Saved workflows continue to load unchanged. Update Asset Tags gains a Mode selector with backward-compatible default: - Set (default): existing behavior, replaces the tag list via the resource endpoint with Basic auth. - Append: new path hitting POST /:resource_type/tags with command=add and signed auth, preserving existing tags. Shared appendTags helper lives in operations/tagAppend.ts and is wired into both resources' updateTags handlers. The tag-action endpoint scopes lookups by (resource_type, type) and silently returns public_ids: [] for assets stored under a non-default type. Append mode now exposes a Type field (labeled to match the asset object's "type" property) and threads it into the signed body, so authenticated/private assets resolve correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The codex "node" field declared n8n-nodes-base.cloudinary, the reserved namespace for n8n's built-in core nodes. This package's real node type is n8n-nodes-cloudinary.cloudinary (package name + node "name"). n8n's loader binds a .node.json codex to its node by filename adjacency (Cloudinary.node.json <-> Cloudinary.node.js) and never reads this field, so the change is cosmetic — but the previous value was misleading, implying this community node lived in n8n-nodes-base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node already uploads and manages videos, but nothing advertised it: a "video" search in the n8n node panel didn't list Cloudinary, and the docs links and description only mentioned images. - Add a codex "alias" array (video, image, media, transform, transcode, optimize, upload, CDN, asset, DAM). The node creator's search keys only on displayName and codex.alias, so this is what makes Cloudinary match "video" (the same mechanism the Google Gemini and MiniMax nodes use). - Mention images and videos in the node description (tooltip + the AI-agent tool schema exposed via usableAsTool). - Add the video best practices guide to primaryDocumentation. All UI-only metadata, so no typeVersion bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces transform:optimizeImage, resizeImage, cropImage, convertImage, optimizeVideo, trimVideo, videoThumbnail, customTransformation, and multiStep. All operations build a Cloudinary delivery URL (no API call); callers chain n8n's HTTP Request node to materialize bytes. Delivery host resolution mirrors the SDK: default res.cloudinary.com (shared), <cloud>-res.cloudinary.com (private CDN), or custom CNAME — driven by new optional privateCdn / secureDistribution credential fields (backwards-compat; default off reproduces today's behaviour. Component builders in operations/transform/shared.ts are shared between standalone ops and the multi-step chain; both consumers map to the same builder to prevent drift. Handlers carry 25 new Vitest cases (156 total) asserting returned JSON rather than a request spy. Also includes: trailingMediaFormat fix for public_ids that embed their own extension, codex alias additions (resize/crop/thumbnail), and CLAUDE.md documentation for the third flow + no-HTTP test pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> EOF )
…l chaining Transform resource increment on top of the 8 delivery-URL operations: - Video Player: new operation that builds a player.cloudinary.com embed URL plus a player_config object (autoplay, loop, muted, source types, poster, colors, skin, fluid/aspect ratio, font, and advanced toggles). Emits no HTTP request. Embed params use camelCase, matching the embed-page docs (snake_case is also accepted by the embedder — verified in-browser). - Multi-Step: crop step now supports crop-by-aspect-ratio (cropAspectWidth + aspectRatio) alongside crop-by-dimensions (cropWidth/cropHeight), mapping each to the shared crop builder. - Video Thumbnail: optional base transformation (thumbnailBaseTransformation) prepended before the frame selector, so a thumbnail can chain from a previous Trim/Multi-Step transform. - USER_AGENT now reports n8n-nodes-cloudinary/<version> sourced from package.json instead of a hardcoded string. - Bump version 0.0.9 -> 0.1.0; gitignore examples/ (kept local-only) and drop the previously committed social-video POC; CLAUDE.md documents the two-surface field-definition split for transform fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the always-loaded agent context to cut per-turn token cost. CLAUDE.md becomes a one-line `@AGENTS.md` import stub (committed plain text — no symlink, so it survives Windows/CI), and AGENTS.md holds a lean core: commands, six one-line core rules, and a one-paragraph architecture map. The deep reference (three flows, transform builders, backwards-compat taxonomy, conventions) moves verbatim into docs/, linked with plain markdown so it loads only when a task touches that area. AGENTS.md is the cross-agent standard, read directly by Codex/Cursor/etc.; the CLAUDE.md stub keeps Claude Code working with no duplication. Always-loaded footprint: ~109 lines -> ~43. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI lint failed on node-param-description-miscased-json at the new thumbnailBaseTransformation field. The rule flags the word "json" in descriptions and wants "JSON" — but here it is the n8n expression variable $json, which is correctly lowercase. The autofix (and an earlier manual edit) rewrote it to $JSON, which is undefined in n8n and would hand users a broken chaining example. Keep $json and suppress the false positive on that single line with a documented eslint-disable, so the guidance stays runtime-correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address PR review findings on the image/video Transform feature: - fetch/social delivery URLs no longer get a spurious file extension. The format suffix (explicit Convert/Thumbnail format or one recovered from a dotted public_id) is now appended only for stored-asset types (upload/private/authenticated), whose public_id is opaque. For fetch and social sources the identifier is a remote URL / external id, so the conversion rides in the transformation (e.g. f_webp) instead. Keeps one invariant: result.format is set iff the URL carries a trailing .<format>. - fetch remote URLs are now smart-escaped before going into the path, so query strings and fragments (?sig=...#v1) are percent-encoded and reach Cloudinary as the source URL instead of being split off by the browser. Path-safe chars stay readable, mirroring the Cloudinary SDKs. - Multi-Step descriptor: step-specific fields are gated by displayOptions on stepType/cropMode, and the Quality field gets its QUALITY_OPTIONS so an Optimize step can pick a quality level. - Comments: drop internal/working-context detail (delivery-code claim and "see plan") in favor of public, observed behavior. - Release: publish with --provenance and set n8n-workflow peerDependency to "*" per the n8n community-node standard. Adds regression tests for stored-vs-fetch extension handling and for fetch URL escaping (query string, fragment, spaces). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Based on an agentic-POV review of what an AI agent actually sees when building a flow with this node: - Node description now advertises transform/optimize/deliver and video player capabilities, not just upload/manage — it is the agent's first filter when the node is offered as a tool (usableAsTool). - Each Transform operation description states its output (secure_url plus a reusable transformation string; embed_url plus player_config for the Video Player), so an agent can chain steps without running the node first. - The delivery type field leads with the recommended "Upload" and makes the signed-URL limitation prominent (those delivery URLs fail to load). Description only — no behavior change. - Resource selector options get one-line purpose descriptions, the legacy resource is relabeled "Asset (Legacy, by Public ID)" and its operations marked Deprecated, reducing wrong-resource picks (e.g. Library was easy to mistake for asset listing). - Resize/Crop field help notes they do not auto-optimize, and to chain Optimize Image or use Multi-Step for f_auto/q_auto. All changes are UI / tool-schema text — no saved-workflow contract impact. The Multi-Step Quality dropdown flagged during review was already fixed in e3d0730, so no change was needed there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the hand-rolled buildDeliveryUrl() with @cloudinary/url-gen so the SDK owns URL path construction and appends the ?_a= analytics slug automatically (sdkCode I = n8n, product B = Integrations, registered in Appendix C of the Cloudinary SDK analytics spec). Key details: - forceVersion: false suppresses the SDK default v1 on nested public IDs - fixFetchUrl() post-processes fetch URLs to encode & and # that the SDK leaves bare, preventing CDN/browser misparse of the fetch source URL - Analytics is correctly suppressed when the fetch source URL contains a literal ? (per the Cloudinary analytics spec) - ? in non-fetch public IDs now encodes as %3F — correct since a bare ? in a delivery path starts the query string at the CDN level Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A Cloudinary CNAME (secure_distribution) is only available on private-CDN accounts, so the two fields always appear together in a real account. Hiding secureDistribution behind privateCdn: true in the credential UI prevents the impossible state and lets the SDK config be a direct translation of credentials with no inference needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
n8n does not mandate zero runtime dependencies for community nodes — the rule was a project-level choice that is now superseded. Future maintainers will see the dependencies section in package.json directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
eitanp461
commented
Jun 3, 2026
- Move Video Player from the Transform resource to a new singular `Widget` resource: handler relocated to operations/widget/, controls split into widget.fields.ts, shared Public ID/Type fields broadened to both resources, tests and architecture docs updated, handler re-keyed `widget:videoPlayer`. - Revert the codex `node` identifier to `n8n-nodes-base.cloudinary` to match the n8n codex-file spec and published community-node practice. Also bundles accompanying working-tree changes (utils refactor, removal of the updateDisplayName operation, multiStep→combineTransformations handler key, and package metadata cleanup). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prefix Transform operation names/actions with Compose:/Image:/Video: so the alphabetical sort clusters them by category, and curate the resource order (Upload and Transform lead, legacy sinks to the bottom). Update field descriptions that reference the renamed operations. Option values are unchanged, so saved workflows are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hermetic verification that descriptor changes stay additive against the deployed npm baseline (origin/master, published 0.0.9). Extracts the n8n public contract (node/credential names, param name/type/default/ displayOptions, option values) from the compiled node and diffs a baseline worktree against the current checkout, exiting non-zero on any break so it can gate releases. The generated verdict + snapshots are written to .local/ (gitignored) as regenerable, point-in-time output rather than committed prose — re-run docs/compat-check/run.sh to refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire docs/compat-check/run.sh into the PR Check workflow via a new npm run compat-check script. The check builds the deployed baseline in a worktree, so the checkout now uses fetch-depth: 0 to make that commit history available. Also adds the unit-test suite to the gate, since the compat argument relies on metadata-serialization tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verbose, unambiguous name for the dir, npm script, and generated report. No behavioral change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Generate a video player embed" -> "Generate a video player" in the Add-action panel. Display label only; the operation value (videoPlayer) is unchanged, so saved workflows are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cloudinary omits parameters with empty values from the string to sign it
computes server-side. The node was signing every param, so a blank field
(e.g. an empty metadata from "{}") was included in our signature but not the
server's, producing "Invalid Signature". Filter empty (empty-string/null/undefined)
values before signing, and also exclude resource_type/cloud_name, which the
signing spec never includes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the pad family (pad/lpad/mpad) to the Resize fit options on both the standalone Image: Resize op and the Combine Transformations resize step. Padding is a resize-family operation (keeps the whole image), so it belongs here rather than as an aspect-ratio path that would duplicate Crop's c_fill. Each pad mode gains a Pad Background control: Black (default), Auto (b_auto), Blurred (b_blurred), or a solid color. Hex colors are encoded as b_rgb:RRGGBB; named colors pass through. The shared resizeComponents builder and padBackgroundSuffix helper keep the standalone op and Multi-Step in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface crop and resize for videos, not just images. The transformation syntax (c_<fit>/c_fill, dimensions, aspect ratio, focus, pad backgrounds) is identical across media types, so the shared component builders already emit valid video output — only the surfacing was image-only. - resizeImage.ts / cropImage.ts now expose a resourceType-parameterized factory and export both the image and video handlers from it, keeping the transformation logic single-sourced in the builders. - Resize/Crop fields are gated on both operations rather than duplicated. Generative Fill stays image-only (gated on cropImage), so the video op never surfaces it and the handler's gen-fill branch never fires. - Matches the existing per-media-type operation idiom (Image/Video Optimize) and n8n's own Edit Image node (named ops, not a toggle). Also clarify in docs/backwards-compat.md that the frozen contract is what ships on master, not whatever exists on a feature branch — the entire Transform resource is unreleased, so these identifiers were free to design. Backward-compatibility-check: 0 breaking changes (all additive). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional Continue From Transformation field to every single-purpose
Transform op. Wiring an upstream op's transformation output ({{ $json.transformation }})
into it prepends that string before the op's own transformation, compounding the
two into one delivery URL — the cross-node counterpart to Combine Transformations.
The prepend happens once in buildTransformResult; readTransformInput exposes the
trimmed value as TransformInput.continueFrom, so ops get chaining for free with no
per-handler logic. Excluded from Custom Transformation and Combine Transformations,
which control their own strings. Replaces videoThumbnail's bespoke (unreleased)
thumbnailBaseTransformation field with this shared one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The player Transformation field was dropped from the embed URL on the false premise that it only affected the poster. The embed page parses the query into a nested object and feeds the video config's transformation to new cloudinary.Transformation(obj), so a raw string must ride under source[transformation][raw_transformation] to reach the stream (verified against the player source and in-browser with e_blur:1000); a flat value is ignored. Also fail fast on the streaming-profile conflict: an HLS/DASH source type can't be combined with a transformation that pins a format (an f_ component, e.g. an Optimize step's f_auto:video) — Cloudinary rejects 'streaming profile for non-streaming formats'. The handler now throws a clear NodeOperationError instead of letting it surface as a cryptic in-player error. Field descriptions updated accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document, for both humans (README) and agents (docs/transforms.md, AGENTS.md), the three ways transforms compose (single op / in-node Combine Transformations / cross-node Continue From Transformation) and the contract that ties them: an op's transformation output maps to the next op's continueFromTransformation input. Also document the Video Player as a transformation consumer — the embed-URL raw_transformation mapping and the HLS/DASH vs format-selection incompatibility — so the next reader doesn't re-derive what this session worked out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lict
Setting an Aspect Ratio makes the player re-crop the video to those
proportions, merging its own crop into the source transformation. With the
default smart crop that merge places an auto-gravity component wrongly and the
player rejects it ("g_auto must be in a transformation component by itself").
- Expose the player's cropMode as a Crop Mode field (smart/fill/pad), gated on
Aspect Ratio being set; emit source[cropMode] / player_config.cropMode only
when an aspect ratio is set and the mode is non-default.
- Fail fast when Aspect Ratio is set + Crop Mode is smart (default) + a
Transformation is present, pointing at the two fixes (Fill/Pad, or clear
Aspect Ratio) — mirrors the existing HLS/format guard.
- Document the conflict in README and docs/transforms.md; field help avoids
qualifier jargon for non-technical users.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The baseline npm ci / build ran with --silent, so when that step fails on the CI runner (it passes locally on macOS + Node 22/24) the actual error is hidden and the check aborts under set -e with a bare exit 1. Drop --silent on the baseline install/build and log the runner's node/npm versions, so the failing command's output is visible. The candidate build stays silent (it isn't the one failing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 0.0.9 baseline (fbdfa17) committed a package-lock.json whose `resolved` tarball URLs point at Cloudinary's private AWS CodeArtifact registry. `npm ci` trusts those hosts verbatim, so the baseline install failed with E401 on the public PR runner (no CodeArtifact token) — aborting the whole check under set -e, unrelated to any candidate change. It passed locally only because dev machines carry cached CodeArtifact creds. Rewrite any private CodeArtifact host in the baseline lockfile back to the public npm registry before `npm ci`, and pin the install to public npm. Integrity hashes are host-independent so the install still verifies; the baseline is built only to extract its UI contract, so the tarball source is immaterial as long as versions match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Expands the Cloudinary community node from a single declarative class into a maintainable, multi-resource node with full test coverage. Adds Asset, Transform, and Admin operations on top of the existing Upload flow, and restructures the codebase around per-operation handlers so new operations are additive.
This is a large branch; it groups into four themes below.
1. Architecture refactor
Cloudinary.node.tsinto a thinexecute()loop plus a per-operation handler map keyed${resource}:${operation}(operations/index.ts). Adding an operation no longer touchesexecute().displayOptions.showonresource+operation.form-datadep), URL builders, metadata serialization, error extraction. Zero runtime deps beyond then8n-workflowpeer dep (pure-JS SHA-256 in sha256.utils.ts).2. Asset & Admin operations
asset_id-based): Get Asset, Update Display Name, plus Append-mode tag updates.public_id).type,public_id,resource_type,tags,context) so Search results pipe straight into later operations.3. Transform resource (delivery-URL construction)
buildDeliveryUrlresolves shared / private-CDN / custom-CNAME hosts from optionalprivateCdn/secureDistributioncredentials.player.cloudinary.com/embed/) and a self-hostedplayer_configJSON. Signed delivery (s--SIG--) is intentionally not implemented yet (disclaimed in the UI).4. Toolchain, CI & docs
IExecuteFunctionsasserting the request contract (URL / method / auth / body) — no network calls. Delivery-URL ops assert returned JSON instead.package-lock.jsonagainst the public npm registry; aligned node descriptor, peer dep range,.nvmrc, and CI.Testing
npm test— Vitest suite (utils + per-operation handler contracts).npm run lint/npm run buildclean.examples/.