--- name: n8n:public-api description: >- 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. --- # Public API v1 Public API v1 lives in `packages/cli/src/public-api/v1/`, mounted at `/api/v1` with API-key auth and public error formatting via `PublicApiControllerRegistry` (`packages/cli/src/public-api/public-api-controller.registry.ts`). Two rule tiers: **invariants** (never break) and **team defaults** (follow unless an existing public contract forces otherwise). When this skill and the code disagree on a detail, the code wins — so open the files below. That is a reason to check the code, not license to drop a team default. ## Non-negotiable rules - New endpoints are `@PublicApiController` classes under `v1/controllers/`, one `*.public.controller.ts` per feature. A controller is a class — never `export =` (the legacy tuple style; `require-public-api-controller` flags it). - Public API and internal REST are separate HTTP surfaces. A public controller never calls an internal controller/endpoint; both reuse the same service. - Controllers and handlers delegate to a service — never import a repository or `Container.get(…Repository)` (`no-repository-in-public-api-handler`). - Input/output go through DTOs from `@n8n/api-types`; every JSON route declares `@ApiResponse(Dto)`. - Register each controller via a side-effect import in `v1/controllers/index.ts` (`public-api-controllers.test.ts` fails otherwise). - Don't add business logic to legacy `express-openapi-validator` (EOV) handlers. - Migrating a legacy endpoint must not change its public contract. These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`) and can't be silenced inline (`no-public-api-guardrail-disable`). The `off` allowlist there covers pre-existing legacy files only — it's shrink-only, don't add to it. ## Team defaults - List endpoints: cursor-based pagination (internal API uses both cursor- and page-based — don't copy an internal endpoint's model). - Updates: full-object `PUT`, not `PATCH`. A successful `GET` body should be acceptable as a `PUT` body for the same resource (round-trip), aside from server-managed/immutable fields. - Strict input DTOs; output DTOs are an allowlist of public fields. - Never return real secrets/tokens in responses or error details — mask with the resource's sentinel/placeholder (or omit). Echoing that sentinel on `PUT` means keep; any other value replaces. Detail: [Updates and write-only secrets](reference.md#updates-and-write-only-secrets). - "Test connection/config" endpoints validate the request body (test-before-save). ## Architecture Public and internal are sibling routes over one shared, HTTP-agnostic service; neither calls the other. ``` GET /rest/tags → TagsController ┐ JWT auth, internal shape ├─→ TagService GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO ``` Reuse the service behavior. Reuse a DTO only when public and internal contracts are intentionally identical; otherwise make a public-specific DTO that doesn't depend on a UI-oriented internal shape. ## Before editing Open these — they are the source of truth, not this skill: - `v1/controllers/` — copy structure from `tags.public.controller.ts` (list + cursor) or `workflows.public.controller.ts` (`@Param` + `@ProjectScope`), and `index.ts` for the barrel. - Decorators in `packages/@n8n/decorators/src/controller/`: `public-api-controller.ts`, `api-key-scope.ts`, `api-response.ts`, `api-error-response.ts`, `api-summary.ts`, `api-description.ts`, `api-tags.ts`, `route.ts`, `scoped.ts`, `args.ts`, `licensed.ts`. - The OpenAPI generator (reads the decorators above, no hand-written YAML needed for a controller route): `v1/openapi-gen/generate.ts`, `v1/openapi-gen/decorator-routes.ts`. - Pagination helpers: `v1/shared/services/pagination.service.ts` (`decodeCursor`, `encodeNextCursor`). - DTOs: `packages/@n8n/api-types/src/dto/`. - Gating tests: `v1/__tests__/public-api-controllers.test.ts`, `v1/__tests__/scope-parity.test.ts`, `v1/openapi-gen/__tests__/generated-spec-drift.test.ts`. - The internal controller for this resource and its neighboring functional tests. ## Declaring a controller A controller is a class marked `@PublicApiController('/base')` that injects the shared service via its constructor and delegates to it. Copy the shape from an existing controller in `v1/controllers/` with the same operation type and auth model; reuse only what applies. Decorators, all from `@n8n/decorators`: | Decorator | Use | |---|---| | `@PublicApiController('/base')` | Class marker; mounts routes at `/api/v1/base`. | | `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. | | `@ApiKeyScope('res:action')` | API-key grant check. | | `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. | | `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. | | `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. | | `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. | | `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. | | `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). | ## Authorization (easy to get wrong) - `@ApiKeyScope` (what the API key is granted) and `@ProjectScope`/`@GlobalScope` (what the user may do) are independent. Use both when the model needs both. - `@ProjectScope` reads `req.params` as-is and does not remap `id` — name the path param what the resolver expects (`workflowId`, `credentialId`, `projectId`, `dataTableId`, …). A generic `id` often fails. - `@ApiKeyScope` takes a string, `{ anyOf: [...] }`, or `{ allOf: [...] }` — never a bare array. The scope must exist in the permissions registry (`API_KEY_RESOURCES` in `@n8n/permissions`); `scope-parity.test.ts` fails on an orphan scope. ## DTOs - Build the public response shape explicitly; don't return an ORM entity and lean on `@ApiResponse` stripping to hide fields. - Treat the output DTO as an allowlist. Re-check nested relations, ownership fields, tokens, and encrypted values. - Make input DTOs strict so unknown/partial fields aren't silently accepted. - Secrets: never return a real secret; use the resource's sentinel/placeholder (or omit). See [Updates and write-only secrets](reference.md#updates-and-write-only-secrets). ## List endpoints (cursor pagination) Copy the cursor flow from `tags.public.controller.ts`. Use `publicApiPaginationSchema` plus `decodeCursor` / `encodeNextCursor` from the shared pagination service; the cursor is opaque; return `{ data, nextCursor }` (never a bare array) with `nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an existing endpoint's pagination as-is. Detail: [List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination). ## Wiring checklist 1. `v1/controllers/.public.controller.ts` + side-effect import in `v1/controllers/index.ts`. 2. Public DTO in `@n8n/api-types` + export from the barrel (`src/dto/`). 3. `@ApiKeyScope` value exists in the permissions registry. 4. Don't hand-write the OpenAPI path or `x-required-scope` for a controller route — the generator (`v1/openapi-gen/generate.ts`) builds it from your decorators (`@ApiSummary`/`@ApiDescription`/`@ApiTags`/`@ApiKeyScope`/ `@ApiResponse`/`@ApiErrorResponse`). Run the full `pnpm build` and commit the regenerated `handlers//spec/paths/*.generated.yml` fragment(s) and `openapi.decorator-routes.generated.yml` — `generated-spec-drift.test.ts` fails CI if they're stale. `pnpm run build:data` alone is **not** enough after touching a controller: it runs the generator against the already-compiled `dist/`, so a new/changed controller silently doesn't show up unless `tsc` ran first. 5. Add the route to `packages/nodes-base/nodes/N8n/n8n-api-coverage.json`. 6. Tests. ## Testing Always cover: happy path, input-validation failure, missing API-key scope, RBAC denial. Prefer covering the business path in `packages/cli/test/integration/public-api/` (real HTTP + DB); mocked-service unit tests don't replace that. Add the cases that apply (cursor pages, not-found/conflict, no sensitive fields, credential keep/replace, migration contract) — see [Testing matrix](reference.md#testing-matrix). Match the nearest existing tests. ## More detail (reference.md) - [List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination) - [Updates and write-only secrets](reference.md#updates-and-write-only-secrets) - [Test-before-save endpoints](reference.md#test-before-save-endpoints) - [Errors](reference.md#errors) - [Testing matrix](reference.md#testing-matrix) - [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)