SKILL.md
SKILL.mdBrowse 2 files
2,992 tokens
11,953 bytes
Token encoding: o200k_base
Snapshot 8bff5da
1---2name: n8n:public-api3description: >-4 Adds, migrates, or updates n8n Public API v1 endpoints with @PublicApiController5 — public DTOs, API-key and RBAC scopes, cursor pagination, OpenAPI + coverage6 wiring, and tests. Use when working under packages/cli/src/public-api/v1/ or7 when exposing an existing service through /api/v1.8---9 10# Public API v111 12Public API v1 lives in `packages/cli/src/public-api/v1/`, mounted at `/api/v1`13with API-key auth and public error formatting via `PublicApiControllerRegistry`14(`packages/cli/src/public-api/public-api-controller.registry.ts`).15 16Two rule tiers: **invariants** (never break) and **team defaults** (follow unless17an existing public contract forces otherwise). When this skill and the code18disagree on a detail, the code wins — so open the files below. That is a reason to19check the code, not license to drop a team default.20 21## Non-negotiable rules22 23- New endpoints are `@PublicApiController` classes under `v1/controllers/`, one24 `*.public.controller.ts` per feature. A controller is a class — never25 `export =` (the legacy tuple style; `require-public-api-controller` flags it).26- Public API and internal REST are separate HTTP surfaces. A public controller27 never calls an internal controller/endpoint; both reuse the same service.28- Controllers and handlers delegate to a service — never import a repository or29 `Container.get(…Repository)` (`no-repository-in-public-api-handler`).30- Input/output go through DTOs from `@n8n/api-types`; every JSON route declares31 `@ApiResponse(Dto)`.32- Register each controller via a side-effect import in `v1/controllers/index.ts`33 (`public-api-controllers.test.ts` fails otherwise).34- Don't add business logic to legacy `express-openapi-validator` (EOV) handlers.35- Migrating a legacy endpoint must not change its public contract.36 37These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`)38and can't be silenced inline (`no-public-api-guardrail-disable`). The `off`39allowlist there covers pre-existing legacy files only — it's shrink-only, don't40add to it.41 42## Team defaults43 44- List endpoints: cursor-based pagination (internal API uses both cursor- and45 page-based — don't copy an internal endpoint's model).46- Pagination args are always `offset` and `limit` — on service methods, handler47 calls, and repository methods you add. Never `skip`/`take` (TypeORM names).48 Translate to `skip`/`take` only inside a repository, at the TypeORM `find`49 call. The public query string is still `cursor` + `limit`; `offset` is the50 decoded cursor field passed into the service, never a client-facing param.51- Updates: full-object `PUT`, not `PATCH`. A successful `GET` body should be52 acceptable as a `PUT` body for the same resource (round-trip), aside from53 server-managed/immutable fields.54- Strict input DTOs; output DTOs are an allowlist of public fields.55- Never return real secrets/tokens in responses or error details — mask with the56 resource's sentinel/placeholder (or omit). Echoing that sentinel on `PUT`57 means keep; any other value replaces. Detail:58 [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).59- "Test connection/config" endpoints validate the request body (test-before-save).60 61## Architecture62 63Public and internal are sibling routes over one shared, HTTP-agnostic service;64neither calls the other.65 66```67GET /rest/tags → TagsController ┐ JWT auth, internal shape68 ├─→ TagService69GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTO70```71 72Reuse the service behavior. Reuse a DTO only when public and internal contracts73are intentionally identical; otherwise make a public-specific DTO that doesn't74depend on a UI-oriented internal shape.75 76## Before editing77 78Open these — they are the source of truth, not this skill:79 80- `v1/controllers/` — copy structure from `tags.public.controller.ts` (list +81 cursor) or `workflows.public.controller.ts` (`@Param` + `@ProjectScope`), and82 `index.ts` for the barrel.83- Decorators in `packages/@n8n/decorators/src/controller/`:84 `public-api-controller.ts`, `api-key-scope.ts`, `api-response.ts`,85 `api-error-response.ts`, `api-summary.ts`, `api-description.ts`, `api-tags.ts`,86 `route.ts`, `scoped.ts`, `args.ts`, `licensed.ts`.87- The OpenAPI generator (reads the decorators above, no hand-written YAML88 needed for a controller route): `v1/openapi-gen/generate.ts`,89 `v1/openapi-gen/decorator-routes.ts`.90- Pagination helpers: `v1/shared/services/pagination.service.ts`91 (`decodeCursor`, `encodeNextCursor`).92- DTOs: `packages/@n8n/api-types/src/dto/`.93- Gating tests: `v1/__tests__/public-api-controllers.test.ts`,94 `v1/__tests__/scope-parity.test.ts`,95 `v1/openapi-gen/__tests__/generated-spec-drift.test.ts`.96- The internal controller for this resource and its neighboring functional tests.97 98## Declaring a controller99 100A controller is a class marked `@PublicApiController('/base')` that injects the101shared service via its constructor and delegates to it. Copy the shape from an102existing controller in `v1/controllers/` with the same operation type and auth103model; reuse only what applies. Decorators, all from `@n8n/decorators`:104 105| Decorator | Use |106|---|---|107| `@PublicApiController('/base')` | Class marker; mounts routes at `/api/v1/base`. |108| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |109| `@ApiKeyScope('res:action')` | API-key grant check. |110| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |111| `@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. |112| `@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. |113| `@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. |114| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |115| `@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). |116 117## Authorization (easy to get wrong)118 119- `@ApiKeyScope` (what the API key is granted) and `@ProjectScope`/`@GlobalScope`120 (what the user may do) are independent. Use both when the model needs both.121- Name every path param `{resource}Id` (e.g. `workflowId`, `credentialId`,122 `projectId`, …) — never a generic `:id` / `{id}`. This is the Public API's123 naming convention: it keeps the API self-documenting and gives typed SDK124 codegen a real argument name instead of `id`. `@ProjectScope` also reads125 `req.params` as-is and does not remap `id` — it resolves authorization by126 exact key name (`workflowId`, `credentialId`, `projectId`, `dataTableId`,127 …), so a generic `id` on a `@ProjectScope` route often fails outright; a128 `@GlobalScope` or unscoped route won't fail the same way, but still follow129 the convention.130- `@ApiKeyScope` takes a string, `{ anyOf: [...] }`, or `{ allOf: [...] }` — never131 a bare array. The scope must exist in the permissions registry132 (`API_KEY_RESOURCES` in `@n8n/permissions`); `scope-parity.test.ts` fails on an133 orphan scope.134 135## DTOs136 137- Build the public response shape explicitly; don't return an ORM entity and lean138 on `@ApiResponse` stripping to hide fields.139- Treat the output DTO as an allowlist. Re-check nested relations, ownership140 fields, tokens, and encrypted values.141- An output DTO restricts which fields you return, not which values they may hold.142 The registry parses the handler's return value against it, so a value the schema143 rejects becomes a `500`. Keep the schema loose enough for anything an existing144 row may contain.145- Build the response from the relations the route loaded, not from the entity type.146 TypeORM relations are opt-in, so two routes over the same entity can return147 different shapes.148- Make input DTOs strict so unknown/partial fields aren't silently accepted:149 `Z.class(shape, { strict: true })`.150- Secrets: never return a real secret; use the resource's sentinel/placeholder151 (or omit). See [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).152 153## List endpoints (cursor pagination)154 155Copy the cursor flow from `tags.public.controller.ts`. The input DTO takes156`limit: publicApiPaginationSchema.limit` plus `cursor: z.string().optional()` —157pick `limit` off the schema, never spread the whole `publicApiPaginationSchema`158(it also exports `offset`, which must never be a Public API query param). Use159`decodeCursor` / `encodeNextCursor` from the shared pagination service; the160cursor is opaque; return `{ data, nextCursor }` (never a bare array) with161`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an162existing endpoint's cursor semantics as-is — but an `offset` param is a163defect to remove, not a contract to preserve. Detail:164[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination).165 166## Wiring checklist167 1681. `v1/controllers/<feature>.public.controller.ts` + side-effect import in169 `v1/controllers/index.ts`.1702. Public DTO in `@n8n/api-types` + export from the barrel (`src/dto/`).1713. `@ApiKeyScope` value exists in the permissions registry.1724. Don't hand-write the OpenAPI path or `x-required-scope` for a controller173 route — the generator (`v1/openapi-gen/generate.ts`) builds it from your174 decorators (`@ApiSummary`/`@ApiDescription`/`@ApiTags`/`@ApiKeyScope`/175 `@ApiResponse`/`@ApiErrorResponse`). Run the full `pnpm build` and commit176 the regenerated `handlers/<feature>/spec/paths/*.generated.yml` fragment(s)177 and `openapi.decorator-routes.generated.yml` —178 `generated-spec-drift.test.ts` fails CI if they're stale. `pnpm run179 build:data` alone is **not** enough after touching a controller: it runs180 the generator against the already-compiled `dist/`, so a new/changed181 controller silently doesn't show up unless `tsc` ran first.1825. Add the route to `packages/nodes-base/nodes/N8n/n8n-api-coverage.json`.1836. Tests.184 185## Testing186 187Always cover: happy path, input-validation failure, missing API-key scope, RBAC188denial. Prefer covering the business path in189`packages/cli/test/integration/public-api/` (real HTTP + DB); mocked-service unit190tests don't replace that. Add the cases that apply (cursor pages,191not-found/conflict, no sensitive fields, credential keep/replace, migration192contract) — see [Testing matrix](reference.md#testing-matrix). Match the nearest193existing tests.194 195## More detail (reference.md)196 197- [List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination)198- [Updates and write-only secrets](reference.md#updates-and-write-only-secrets)199- [Test-before-save endpoints](reference.md#test-before-save-endpoints)200- [Errors](reference.md#errors)201- [Testing matrix](reference.md#testing-matrix)202- [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)203- [Verifying a migration](reference.md#verifying-a-migration)204- [CI and merging](reference.md#ci-and-merging)205 Discovery context
Discovered by repository scan. No exact path reference found in the snapshot’s root AGENTS.md.
