SKILL.md
2,992 tokens · o200k_base · 11,953 bytes
Source excerpt starting at line 1.---name: n8n:public-apidescription: >- 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 unlessan existing public contract forces otherwise). When this skill and the codedisagree on a detail, the code wins — so open the files below. That is a reason tocheck 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'tadd 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).- Pagination args are always `offset` and `limit` — on service methods, handler calls, and repository methods you add. Never `skip`/`take` (TypeORM names). Translate to `skip`/`take` only inside a repository, at the TypeORM `find` call. The public query string is still `cursor` + `limit`; `offset` is the decoded cursor field passed into the service, never a client-facing param.- 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 ├─→ TagServiceGET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO``` Reuse the service behavior. Reuse a DTO only when public and internal contractsare intentionally identical; otherwise make a public-specific DTO that doesn'tdepend 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 theshared service via its constructor and delegates to it. Copy the shape from anexisting controller in `v1/controllers/` with the same operation type and authmodel; 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.- Name every path param `{resource}Id` (e.g. `workflowId`, `credentialId`, `projectId`, …) — never a generic `:id` / `{id}`. This is the Public API's naming convention: it keeps the API self-documenting and gives typed SDK codegen a real argument name instead of `id`. `@ProjectScope` also reads `req.params` as-is and does not remap `id` — it resolves authorization by exact key name (`workflowId`, `credentialId`, `projectId`, `dataTableId`, …), so a generic `id` on a `@ProjectScope` route often fails outright; a `@GlobalScope` or unscoped route won't fail the same way, but still follow the convention.- `@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.- An output DTO restricts which fields you return, not which values they may hold. The registry parses the handler's return value against it, so a value the schema rejects becomes a `500`. Keep the schema loose enough for anything an existing row may contain.- Build the response from the relations the route loaded, not from the entity type. TypeORM relations are opt-in, so two routes over the same entity can return different shapes.- Make input DTOs strict so unknown/partial fields aren't silently accepted: `Z.class(shape, { strict: true })`.- 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`. The input DTO takes`limit: publicApiPaginationSchema.limit` plus `cursor: z.string().optional()` —pick `limit` off the schema, never spread the whole `publicApiPaginationSchema`(it also exports `offset`, which must never be a Public API query param). Use`decodeCursor` / `encodeNextCursor` from the shared pagination service; thecursor is opaque; return `{ data, nextCursor }` (never a bare array) with`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve anexisting endpoint's cursor semantics as-is — but an `offset` param is adefect to remove, not a contract to preserve. Detail:[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination). ## Wiring checklist 1. `v1/controllers/<feature>.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/<feature>/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, RBACdenial. Prefer covering the business path in`packages/cli/test/integration/public-api/` (real HTTP + DB); mocked-service unittests don't replace that. Add the cases that apply (cursor pages,not-found/conflict, no sensitive fields, credential keep/replace, migrationcontract) — see [Testing matrix](reference.md#testing-matrix). Match the nearestexisting 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)- [Verifying a migration](reference.md#verifying-a-migration)- [CI and merging](reference.md#ci-and-merging)
Discovery context
Discovered by repository scan. No exact path reference found in the snapshot’s root AGENTS.md.