n8n:public-api

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

Adds, migrates, or updates n8n Public API v1 endpoints with @PublicApiController — public DTOs, API-key and RBAC scopes, cursor pagination, OpenAPI + coverage wiring, and tests. Use when working under packages/cli/src/public-api/v1/ or when exposing an existing service through /api/v1.

.agents/skills/public-api/SKILL.md

Download bundle ↓
master · 8bff5da2 bundle filesScanned 2026-09-15

reference.md

2,392 tokens · o200k_base · 10,276 bytes

Public API v1 — reference

Detail for tasks that need it. Essentials and the rule tiers are in SKILL.md. Open the cited files; they are the source of truth.

List endpoints and cursor pagination

The internal API mixes cursor- and page-based pagination. New Public API list endpoints use cursor-based pagination — do not copy an internal controller's model.

Copy the working flow from v1/controllers/tags.public.controller.ts (and workflows.public.controller.ts for a @Param list) rather than pasting a snippet here — a copy would drift. The moving parts:

  • Input DTO takes limit: publicApiPaginationSchema.limit plus an opaque cursor: z.string().optional() — cherry-pick limit but never spread the whole publicApiPaginationSchema. That schema also exports offset, used by internal-API-style page params elsewhere; a Public API list DTO must never expose it as a query param. See ListTagsQueryDto for the shape to copy.
  • decodeCursor / encodeNextCursor live in v1/shared/services/pagination.service.ts. Decode the incoming cursor to { offset, limit }, guard the decoded shape, and pass offset/limit to the service — never { skip, take }. offset is an internal implementation detail of the cursor here, never a client-facing query param. TypeORM's skip/take stay inside the repository, at the find call.
  • Treat the cursor as opaque; never hand-encode a token.
  • Return an envelope { data, nextCursor } — never a bare array.
  • encodeNextCursor(...) returns null when there is no further page; surface that as nextCursor: null.
  • An invalid/undecodable cursor is a 400 via the existing bad-request error.
  • For an existing list endpoint, keep its current cursor semantics unchanged. A leaked offset query param (DTO spreading publicApiPaginationSchema instead of picking limit) is a defect to remove, not a contract to preserve — decorator-routed DTOs validate via a plain z.object(), which silently strips unknown query keys rather than rejecting them, so removing offset from the DTO makes it inert rather than erroring for existing callers.

The output DTO wraps the list as { data, nextCursor } and is declared with @ApiResponse(...) so the registry strips undeclared fields.

Updates and write-only secrets

  • Default to PUT. The update DTO describes the full mutable object; the validation layer rejects a partial payload (typically 400). Don't implement merge semantics behind a PUT.
  • Don't add a new PATCH unless the task explicitly requires partial-update semantics or an established resource-specific exception applies.
  • Migrating an update endpoint keeps its current public HTTP semantics.

Write-only secrets (credentials, tokens, keys) support GET→PUT round-trip via a resource-specific sentinel/placeholder (e.g. credentials use CREDENTIAL_BLANKING_VALUE / related helpers — do not invent a new format):

  • GET never returns the real secret; it returns that sentinel (or omits the field). Never echo a real secret in responses or error details (including test-connection and upstream error messages).
  • On PUT, sending the exact sentinel from GET means keep the stored secret. Sending any other value means replace it. Do not treat "looks masked" or "field omitted" as keep unless the resource helper/tests say so.
  • Reuse the resource's redact/unredact (or equivalent) helper in the service — don't reimplement sentinel detection or credential merge in the controller, and never persist the sentinel as a real secret.
  • Sentinel support does not make the whole PUT a partial update; other required client-manageable fields stay required. Server-managed/immutable fields from GET (id, timestamps, …) follow the resource DTO (ignored or not required on write).

Test-before-save endpoints

A connection/config-test endpoint validates the config in the request body, not stored state — unless the endpoint explicitly verifies an already-saved resource. Secret handling is the same as any other endpoint — see above.

Errors

  • Don't leak persistence errors, stack traces, or internal messages. Reuse existing domain errors when the registry already maps them to the right public errors; otherwise map at the controller boundary.
  • Follow the error semantics of the nearest existing public controller; invalid input and invalid cursors use the existing bad-request pattern.
  • When migrating, preserve the documented status codes and public error behavior.

Testing matrix

Always (in SKILL.md): happy path, input-validation failure, missing API-key scope, RBAC denial. Add whichever apply, matching the nearest existing tests:

  • At least one integration test under packages/cli/test/integration/public-api/ that exercises the real service/DB path for the main success case (and paging/RBAC where they matter). Controller unit tests with a mocked service are fine for wiring/validation edges — not as the only coverage of behavior.
  • Cursor paging: first page, final page with nextCursor: null, invalid cursor, limit handling.
  • Not-found and conflict semantics.
  • Response carries no sensitive/internal fields.
  • Credential resources: response has no real secret (sentinel or omitted); PUT with the exact sentinel keeps the secret; any other value replaces it; the sentinel is never persisted as a real secret.
  • Migration: path, method, status codes, scope, and response contract are unchanged. Tests alone cannot show this — see Verifying a migration.

Migrating legacy EOV endpoints

Legacy express-openapi-validator endpoints live under v1/handlers/, wired through openapi.yml with x-eov-operation-* and request types in packages/cli/src/public-api/types.ts. Treat these as migration targets, not templates.

  • Prefer migrating to @PublicApiController over extending the handler.
  • Preserve the public contract: path, method, scopes, status codes, response shape, and pagination.
  • Move HTTP concerns into the controller and business orchestration into the shared service; keep the controller a thin HTTP boundary. If the legacy handler was itself already just a thin wrapper around an internal @RestController (calling its methods directly, e.g. Container.get(SomeController).createThing(req, res, payload)), the new public controller can call that same internal controller directly — no need to duplicate its validation/business logic.
  • A route must be served by either the EOV handler or a controller, not both — the build's mergeDecoratorDocument (v1/openapi-gen/generate.ts) throws on a path+method declared by both sides. Remove the legacy wiring (its path's $ref entry in openapi.yml, the x-eov-operation-* handler, and its handler.ts) only after the new controller is registered, pnpm build regenerates the spec cleanly, and tests are updated.
  • Fully delete the migrated legacy files: the handler's .ts, its spec/paths/*.yml and spec/schemas/*.yml, and any now-dead request type in packages/cli/src/public-api/types.ts. Then check v1/shared/spec/schemas/_index.yml and v1/shared/spec/parameters/_index.yml for entries that $ref one of the deleted schema/parameter files — those are separate from the path's own $ref in openapi.yml and are easy to miss; left dangling, the next bundle fails on a broken $ref.
  • If the legacy handler gated on a license (isLicensed('feat:x') middleware), @Licensed('feat:x') now replicates that for a controller route (see the decorator table in SKILL.md) — but only for a single feature. If the legacy check was an any-of/all-of over several flags (e.g. LicenseState.isProvisioningLicensed()), @Licensed can't express that; replicate it manually in the controller instead, don't drop it - this is exactly what the internal provisioning.controller.ee.ts and role-mapping-rule.controller.ee.ts already do, since neither uses @Licensed for that reason.
  • As a legacy file drops repository access / the export = tuple, remove its entry from the off allowlists for no-repository-in-public-api-handler and require-public-api-controller in packages/cli/eslint.config.mjs (shrink-only — never extend them).
  • For complex legacy-only, multipart, or non-standard endpoints, study the nearest existing handler first.
  • Keep each field in its original position when you extract a request shape shared by two routes, and destructure out the ones a route doesn't take. The generator emits properties in shape order, so a moved field rewrites the *.generated.yml of a route the PR wasn't changing.

Verifying a migration

Tests are written against the new code, so they can't show that the old behavior survived. These checks can:

  • Diff live responses against master. Run the route on an instance per branch and compare status, body, and error message for the same requests. Reading the old YAML beside the new Zod schema doesn't find the differences.
  • Check whether a field is absent or null. activeVersion omitted is not the same response as activeVersion: null; use a conditional spread to omit it.
  • Check query-param coercion at the edges ("", "0", "false", absent). A Zod DTO and the old validator don't coerce identically.
  • Request a route the PR didn't migrate. A shared DTO change can stop the spec bundle compiling at startup, which turns every legacy route into a 500 without failing a test.

CI and merging

Two failures that a migration hits outside the code itself:

  • Merge master in before merging. A generator change on master leaves every branch's committed *.generated.yml stale, and generated-spec-drift.test.ts then fails only on the merge commit, so the PR itself stays green and MERGEABLE. gh pr checks hides merge_group runs; look for the run on gh-readonly-queue/master/pr-<number>-<sha>.
  • Ask a maintainer for a /size-limit-override early. Generated YAML counts toward the 1,000-line PR size limit, so a single-route migration can exceed it on generator output alone.
Referenced from SKILL.md