# Plugin API Reference

The public plugin surface re-exported by @nextlyhq/plugin-sdk.

> **Alpha (`0.x`) — pin your versions.** `@nextlyhq/plugin-sdk` **is** the stability boundary — if it isn't exported there, it isn't public. Most of this surface is now `@public` (semver-protected); some is still `@experimental`. See [API stability](https://nextlyhq.com/docs/plugins/stability.md) for the exact ledger.

## Entry points

| Import                         | Surface                                                                                                                        | React?              |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| `@nextlyhq/plugin-sdk`         | `definePlugin`, `contributes`/`ctx`/hook/event/filter types, `PermissionSlug`/`EventName`, `secret()`                          | no                  |
| `@nextlyhq/plugin-sdk/testing` | `createTestNextly`                                                                                                             | no                  |
| `@nextlyhq/plugin-sdk/client`  | `useCan`, `<Can>`                                                                                                              | yes (optional peer) |
| `@nextlyhq/plugin-sdk/admin`   | `registerComponent`, `registerComponents`, `registerKnownPlugin`, `ChallengeViewProps`, `ChallengeResolveResult`               | yes (optional peer) |
| `@nextlyhq/plugin-sdk/routing` | `slugToStaticParam`                                                                                                            | no                  |
| `@nextlyhq/plugin-sdk/db`      | Drizzle query operators (`eq`, `and`, `inArray`, …); `sql` is not among them                                                   | no                  |
| `@nextlyhq/plugin-sdk/widgets` | `WidgetComponentProps`, `WidgetSlot`, `WidgetResult`, `WidgetResultField`, `WidgetQueryBatchResponse` — types only, no runtime | no                  |

## `definePlugin(def): PluginDefinition`

```ts
interface PluginDefinition {
  name: string;
  version: string;
  nextly: string;                              // core-compat range (boot-checked, may span majors)
  dependsOn?: Record<string, string>;          // required deps → version range
  optionalDependsOn?: Record<string, string>;  // enhance-if-present
  enabled?: boolean;                           // default true; false = skip behavior, keep schema
  contributes?: PluginContributions;
  capabilities?: PluginCapabilities;           // { net?: { outbound }, db?: { rawSql }, secrets?, auth?: { login } }
  provides?: string[];                         // capability names, e.g. "acme/auth-provider"
  requires?: Record<string, string>;           // capability → provider version range (checked, not ordering)
  schemaVersion?: number;
  setup?:   (config: NextlyConfig) => NextlyConfig;   // all setups before any init
  init?:    (ctx: PluginContext) => void | Promise<void>;
  onReady?: (ctx: PluginContext) => void | Promise<void>; // after every init and route registration
  destroy?: (ctx: PluginContext) => void | Promise<void>;
  rename?:  (map: Record<string, string>) => PluginDefinition;  // integrator-side remap (D54)
}
```

## `PluginContributions`

```ts
interface PluginContributions {
  collections?: CollectionConfig[];
  singles?:     SingleConfig[];
  fieldGroups?: FieldGroupConfig[];
  extend?:      Array<{ target: string | string[]; fields: FieldConfig[] }>;
  permissions?: PluginPermission[];            // { action, resource, label?, description?, group? }
  fieldTypes?:  PluginFieldType[];             // custom field types — see below
  events?:      Array<{ name: string }>;       // each must begin with your slug and a dot, or boot fails
  routes?:      PluginRoute[];                 // { method, path, handler, requiredPermission?, public?, middleware?, rateLimit?, rawBody?, csrf?, noStore?, formatTimestamps?, mount? }
  auth?:        PluginAuthContributions;       // hooks, challenges, ui — see Auth extensibility
  settings?:    ZodObject;                     // gives ctx.settings
  audit?:       PluginAuditDeclaration;        // gives ctx.audit
  hookPoints?:  PluginHookPointDeclaration[];
  admin?: {
    menu?:     PluginMenuItem[];               // { label, to, icon?, order?, requiredPermission?, children? }
    pages?:    PluginAdminPage[];              // { path, component, requiredPermission? }
    settings?: { component: ComponentPath };
    views?:    Record<string, PluginCollectionView>; // list/edit/before*/after* by collection slug
    widgets?:  PluginAdminWidget[];             // rendered on the dashboard; @experimental
  };
}
```

Component references are string paths: `"<package>/<path>#<Export>"`, e.g. `"@acme/x/admin#ReportsPage"`.

## `PluginContext` (`ctx`)

```ts
interface PluginContext {
  services: NextlyServices;       // managed, secure-by-default; .collections supports { as: 'user' | 'system' }
  db: PluginDatabase;             // Drizzle builder + transaction; the live instance only with capabilities.db.rawSql
  auth: PluginAuthApi;            // always present; completeLogin needs capabilities.auth.login
  settings?: PluginSettingsApi;   // with contributes.settings
  fetch?: typeof fetch;           // with a non-empty capabilities.net.outbound
  audit?: PluginAuditApi;         // with contributes.audit
  hooks: PluginHookRegistry;      // in-transaction, modify/abort
  events: EventBus;               // post-commit, observe-only, best-effort
  filters: PluginFilterRegistry;  // transform values on typed seams (D63)
  actions: PluginActionRegistry;  // ordered side-effects on typed seams (D63)
  self: { name: string; collections: Record<string,string>; singles: Record<string,string> }; // resolved slugs (D54)
  config: Readonly<NextlyConfig>;
  logger: Logger;
  nextlyVersion: string;
}
```

### Secure-by-default services (D35)

```ts
// Acts as the current user (RBAC enforced):
await ctx.services.collections.listEntries(slug, {}, { as: "user", user: ctx.user });
// Privileged work — explicit, visible elevation:
const { item, warnings } = await ctx.services.collections.createEntry(slug, data, { as: "system" });
```

Writes (`createEntry`, `updateEntry`, `deleteEntry`) resolve to the same
`{ message, item, warnings? }` envelope the Direct API and the REST API return.
`warnings` is present only when a post-commit hook failed: the row is already
saved at that point, so the write succeeds and the failure travels beside it.
Reads are unchanged and return their value directly.

## Hooks & events

```ts
ctx.hooks.on(type, collectionSlug | "*", handler);   // beforeCreate/afterCreate/… — can modify/abort
ctx.events.on(eventName, handler);                   // observe-only; e.g. `collection.${slug}.created`
ctx.events.emit(eventName, payload);                 // your own `<slug>.…` names, declared in contributes.events
```

Event-name constants are exported: `DocumentEvents`, `AuthEvents`, `MediaEvents`.

A plugin emits and declares only names beginning with its own slug and a dot, the slug
being `pluginAdminSlug(name)`: `@acme/billing` emits `acme-billing.charged`.
`ctx.events.emit` throws `FORBIDDEN` for any other name, core's included, and a name in
`contributes.events` outside the slug fails boot (`plugin-event-outside-namespace`).

## Typed slugs (codegen)

`CollectionSlug`, `SingleSlug`, `PermissionSlug`, and `EventName` are `string` by default and **narrow to the installed unions** once `nextly generate:types` runs. Author code stays valid either way.

## Client (`/client`)

```ts
useCan(permission: string): boolean;
const Can: React.FC<{ permission: string; fallback?: ReactNode; children: ReactNode }>;
```

## Admin registration (`/admin`)

```ts
registerComponent(path, Component);
registerComponents({ [path]: Component });
registerKnownPlugin(packagePrefix, async () => { /* register */ });
```

When you run `nextly generate:types`, a `plugin-admin-imports.generated.ts` is emitted that calls `registerComponents` for you — import it once in the app and your `contributes.admin` components load with no manual wiring.

## Testing (`/testing`)

```ts
createTestNextly(opts: {
  plugins: PluginDefinition[];
  collections?: CollectionConfig[];
  dialect?: "sqlite" | "postgresql" | "mysql";  // default sqlite (in-memory)
  serverUrl?: string;                           // else TEST_POSTGRES_URL / TEST_MYSQL_URL
}): Promise<{ nextly; getService; hooks; events; adapter; destroy }>;

getConfiguredTestDialects(): TestDialect[];  // sqlite, plus any whose server URL is set
```

## Custom field types (`contributes.fieldTypes`)

A plugin can add a field type. It persists as one of the existing storage
primitives — the plugin never invents DDL — and renders through its own admin
component.

```ts
interface PluginFieldType {
  type: string;                    // field.type value, e.g. "rating". Must not collide with a built-in
  storage: FieldStoragePrimitive;  // text | longText | boolean | number | timestamp | json
  component: ComponentPath;        // "<package>/<path>#<Export>"
  label?: string;                  // picker label; defaults to a title-cased `type`
  description?: string;
  icon?: string;                   // Lucide icon name
  category?: FieldTypeCategory;    // picker grouping; defaults to "Advanced"
  surfaces?: FieldSurface[];       // where it may be offered; defaults to entries/singles only
  layout?: "takeover";
  validate?: (value, args) => true | string | PluginFieldIssue[] | Promise<…>;  // see below
  validateOptions?: (field) => true | string | PluginFieldIssue[];              // see below
}
```

### `validate` — the type's own rules

Without it a custom type is only ever checked as its storage primitive: a
`json`-backed type would accept any JSON at all. Declare the rules on the type
and every instance gets them, rather than each schema author remembering to
repeat a field-level `validate`.

```ts
fieldTypes: [
  {
    type: "rating",
    storage: "number",
    component: "@acme/ratings/admin#RatingInput",
    validate(value, { field }) {
      // Already a number: the `number` primitive's rules ran first.
      if (typeof value !== "number" || !Number.isInteger(value)) {
        return "Rating must be a whole number";
      }
      const max = typeof field.max === "number" ? field.max : 5;
      if (value > max) return `Rating cannot exceed ${max}`;
      return true;
    },
  },
]
```

`args` carries:

| Key     | What it is                                                                                                                                                                                                                            |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data`  | The write payload, for rules spanning fields. On update this is the patch, not the merged stored entry. Always the top-level payload, even for a field nested in a repeater row.                                                      |
| `req`   | Request context; carries `user` when authenticated. Empty for a field inside a field group instance.                                                                                                                                  |
| `field` | The instance, so you can read the options your type declares. A detached copy: records, arrays, dates, sets and maps are rebuilt, so editing them changes nothing. Functions and class instances stay shared — treat it as read-only. |
| `path`  | Where this field sits in the write (`"stars"`, `"rows[1].stars"`).                                                                                                                                                                    |
| `mode`  | `"create"` or `"update"`.                                                                                                                                                                                                             |

Return `true` to accept, a string for one problem, or an array when one value
can be wrong in several places at once. Issue paths are used as given, so build
them from `args.path` or they will be wrong for a nested instance:

```ts
return [
  { path: `${path}.nodes[2]`, code: "DISALLOWED", message: "That block is not allowed here" },
  { message: "Document exceeds the node limit" },   // path/code default to the field's own
];
```

Ordering: the built-in rules for your `storage` primitive run first, then this,
then the field's own `validate` — so a schema author adds rules on top of yours
rather than replacing them. Two values never reach `validate`: an absent one
(that is what `required` is for) and one the storage primitive already refused,
so you never have to re-check that you were handed the shape your type stores.
For `json` that check is representability — a cycle or a `BigInt` is rejected
before you see it — and nothing more, so the shape of the document is yours to
define.

Anything you return outside `true | string | PluginFieldIssue[]` is a refusal,
as is throwing — a validator that forgets to return fails the write rather than
silently accepting everything.

**Where it runs.** The entry, single, and field group write paths. A type offered
on the `users`, `forms`, or `blocks` surface does **not** run it yet: those
surfaces validate through their own paths. A disabled plugin keeps its field
types registered (its collections are retained) but its `validate` does not
run — that is behavior, and a disabled plugin contributes none.

### `validateOptions` — rules about the declaration

`validate` answers "is this value allowed in this field". `validateOptions`
answers "is this field declared coherently at all". It runs when a schema is
registered, not when a value is written.

The distinction matters because a schema defect reported per write is reported
to the wrong person: the writer cannot fix it, and it fails every write until
whoever declared the field notices.

```ts
validateOptions(field) {
  const policy = field.policy;
  if (policy === undefined) return true;
  if (policy === null || typeof policy !== "object") {
    return [{ path: "policy", message: "policy must be an object" }];
  }
  const { kinds } = policy as { kinds?: unknown };
  if (Array.isArray(kinds) && kinds.length === 0) {
    // Nothing could ever be stored in this field. That is a contradiction in
    // the declaration, not a problem with any particular value.
    return [{ path: "policy.kinds", message: "policy.kinds cannot be empty" }];
  }
  return true;
}
```

Synchronous on purpose: a declaration is checked against itself, and a
config-time rule that needed I/O would make startup depend on something that can
be down. Issue paths are **relative** and get appended to the field's own, so
`"policy.kinds"` reports against `fields[2].policy.kinds` — unlike `validate`,
whose paths are absolute, because this one only ever names an option it already
knows and has no way to learn its own index. Throwing is a rejected declaration,
not a crash. A returned `code` is **not** carried: config errors report through
closed, public code unions, so the canonical member is used and your message
carries the detail.

**Where it runs.** Every path a declaration reaches storage by:

| Path                                                   | When                                                                                          |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| boot                                                   | over the resolved config and a plugin's own raw contributions, once the registry is populated |
| `db:sync` and its watcher                              | before anything is serialized or materialized                                                 |
| Schema Builder writes                                  | before the payload is stored, and again before the manifest is written                        |
| direct collection/single/field-group create and update | before the definition is persisted and its DDL runs                                           |
| `nextly build`                                         | over collections, singles and field groups                                                    |
| `migrate:create`                                       | over the merged set the migration is generated from                                           |
| HMR reload                                             | after the reload repopulates the registry                                                     |

**Not** the `define*` calls, despite their being where a code-first config is
validated. The config bundle is evaluated before `contributes.fieldTypes` is
registered, so a custom type is not yet known at that point and the define call
rejects it as an unknown field type before any option check could run. Do not
reach for `defineCollection` to get your declaration checked — that ordering is
why every gate above sits after registration.

Your check always sees the declaration **as written**. On the Builder path that
is the submitted payload, not the parsed copy: the manifest schema drops keys it
does not declare, while the write persists the original, so your options are
present in what gets stored and absent from what was parsed.

A disabled plugin runs neither hook. Its field types stay registered, because its
collections are retained and their fields still have to map to a column and
render, but `validate` and `validateOptions` are both dropped at registration —
they are behavior, and a disabled plugin contributes none.

**Where your options live.** An option written directly on the field is read as
written, and that is the ergonomic choice while its name differs from every key
the field schema declares — `options`, `fields`, `admin`, `label` and the rest of
the built-in field surface. The manifest applies the built-in shape to every
field whatever its type, so a name that does collide is judged against the core
meaning (`options` as a select's choice array) and rejected before your check
runs.

For a name core already uses, or to be safe against names core may add later,
put the option in the `pluginOptions` container. Core never looks inside it, so
any name is legal there:

```json
{
  "name": "rating",
  "type": "star-rating",
  "pluginOptions": { "options": { "presets": ["a", "b"] } }
}
```

Both are read, and your type is handed **one flat view** either way, so
`field.options` above resolves to your object without your having to know where
it was stored. The container wins when the same name appears in both. Writes
from the Schema Builder go to the container, so a field stored the older way
moves there the next time it is saved; nothing you read changes when it does.

Two names are reserved inside the container: **`type` and `name`**. The instance
you are handed states which field it is under those keys, restated after your
options are folded in, so an option using either would be shadowed and never
reach you. A manifest write declaring one is refused rather than silently
losing it.

Fields nested in a `repeater` or `group` are covered. A type offered only on the
`users`, `forms`, or `blocks` surface is **not**: those surfaces have their own
config validators, which do not consult this registry.

**Declaring one on the `users` surface from code.** `UserFieldConfig` is a union
of the built-in field shapes, so a `select` missing its `options` fails on its
own shape. A plugin type's token is not knowable there, and an arm open enough
to accept it would accept that malformed `select` too and lose the error. Wrap
the declaration in `pluginUserField()` instead, which marks it as belonging to
the open arm:

```ts
import { defineConfig, pluginUserField } from "nextly";

export default defineConfig({
  users: {
    fields: [
      { name: "company", type: "text" },
      pluginUserField({ name: "score", type: "star-rating" }),
    ],
  },
});
```

Options are optional there: pass `pluginOptions`, write them directly on the
field, or pass none at all. Note that these options reach type generation but
are not persisted, so a field read back from the database after boot carries
none of them.

## Secrets

```ts
secret(value): Secret<T>;   // auto-redacts in logs / JSON / inspect; .reveal() to read
isSecret(v): boolean;
```

See the [author guide](https://nextlyhq.com/docs/plugins/author-guide.md) for the full workflow and the [error reference](https://nextlyhq.com/docs/plugins/error-reference.md) for boot-error meanings.

## Runtime surfaces added for stateful plugins

`ctx.settings` is present only when the plugin declares
`contributes.settings`, `ctx.fetch` only with a non-empty
`capabilities.net.outbound`, and `ctx.audit` only with `contributes.audit`, so
a plugin that never asked for one does not have it. They are typed as optional
on `PluginContext`; read them through a guard that names the missing
declaration. `ctx.auth` and `ctx.filters` are always present.

### `ctx.settings`

Declare `contributes.settings` (a zod object) to get it.

```ts
if (!ctx.settings) throw new Error("declare contributes.settings");
const config = (await ctx.settings.get()) as MySettings;
await ctx.settings.set({ apiKey: "..." }, { actorUserId: user.id });
```

Values are parsed by the declared schema, and the paths named in
`capabilities.secrets` are encrypted at rest. See
[security](https://nextlyhq.com/docs/plugins/security.md) for what is and is not returned to the admin.

`set` is a JSON merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)):
keys you leave out keep their stored value at every depth, and a key set to
`null` is removed, so `set({ providers: { github: null } })` deletes one entry
of a record. The result is validated as a whole, and a key the schema does not
declare is refused at any depth. One key's stored value may be at most 256 KiB.

`get` throws an internal error when the stored settings no longer fit the
schema — for example after an update added a required key — or hold a secret
nothing can decrypt; the log names the plugin and the paths, and the admin
settings page still opens and shows what is missing.

After any change, from the admin or from `ctx.settings.set`, core emits
`plugin.settings.changed` with `{ plugin, changedKeys }` (key names, never
values), and an operator's change is recorded in the activity log by key name.

### `ctx.fetch`

Declare a non-empty `capabilities.net.outbound` to get it.

```ts
if (!ctx.fetch) throw new Error("declare capabilities.net.outbound");
const res = await ctx.fetch("https://accounts.google.com/.well-known/openid-configuration");
```

Reaches only the declared hosts, and only addresses outside your own network.

### `ctx.audit`

Declare `contributes.audit` to get it.

```ts
if (!ctx.audit) throw new Error("declare contributes.audit");
await ctx.audit.write({
  kind: "acme-auth.identity-linked",
  actorUserId: user.id,
  metadata: { provider: "google" },
});
```

A kind is the plugin's own admin slug, a `.`, a lowercase letter or digit, then
any of lowercase letters, digits, `.`, `_` and `-`; a kind outside that, longer
than the audit column, or declared twice fails boot. Undeclared kinds and keys
are dropped rather than stored. `targetUserId` must name an existing user; a
row naming one that does not exist is stored without its metadata, and a
warning is logged.

### `ctx.auth`

Always present. `completeLogin` requires `capabilities.auth.login` and a
strategy name beginning with the plugin's own slug.

```ts
return ctx.auth.completeLogin(user.id, {
  request,
  strategy: "acme-google-auth:google",
  next: "/admin",
});
```

Also `ctx.auth.currentUser(request)` and `ctx.auth.verifyCsrf(request)`. `currentUser` answers `null` to a POST, PUT, PATCH or DELETE whose `Origin` (or, without one, `Referer`) is neither your site nor an allowed origin, or that names no origin, as it does to a request with no session. `verifyCsrf` accepts the token in the `x-csrf-token` header or as `csrfToken` in a JSON body; it reads at most 64 KB of a body looking for it, and refuses a larger one with `reason: "body-too-large"`, so a large request sends the token in the header.

### `ctx.filters.decide`

For a seam where handlers VETO rather than transform.

```ts
const verdict = await ctx.filters.decide(
  "acme-auth.may-link",
  { allow: true },
  { userId }
);
```

A handler that throws denies, and a handler may only keep or downgrade the
verdict — so the order plugins load in cannot decide access.

### Typed hook points

`ctx.filters` and `ctx.actions` take their types from `HookPointPayloads`
(`@experimental`), keyed by the point's name. Core's seams (`email.beforeSend`,
`email.afterSend`, `admin.nav`, `collections.listQuery`) are listed; a plugin
lists the points it publishes by augmenting the interface:

```ts
declare module "@nextlyhq/plugin-sdk" {
  interface HookPointPayloads {
    "acme-auth.profile": {
      kind: "filter";
      value: { displayName: string };
      context: { userId: string };
    };
    "acme-auth.may-link": { kind: "decision"; context: { email: string } };
    "acme-auth.linked": { kind: "action"; payload: { userId: string } };
  }
}
```

A listed point's handlers and calls are then checked against its entry, and one
used through the wrong registry (a `decision` through `apply`, say) does not
compile. A name with no entry is typed by the call site, as before.

### Lifecycle

`onReady` runs after every plugin has initialised and routes are registered.
There are no install or uninstall hooks yet: they arrive with the install
command that calls them. Do one-time setup in `init`, written so running it on
every boot is harmless.
