feat(core): add plugin extension registry and dependency validation

Introduce the foundational Plugin Extension Protocol — a typed,
declarative mechanism for plugins to extend other plugins.

- BetterAuthExtensionRegistry: empty interface augmented by host
  plugins via declare module (parallel to BetterAuthPluginRegistry)
- extensions field on BetterAuthPlugin: typed via registry, gives
  compile-time checking and IDE autocomplete for extension contracts
- dependencies field on BetterAuthPlugin: validated at startup,
  throws BetterAuthError with descriptive message if missing
- getExtensions(hostId) on AuthContext: collects typed extension
  contributions from all plugins targeting a host
- Dependency validation in runPluginInit: fail-fast before any
  init runs
- Docs: Plugin Extension Protocol section in concepts/plugins.mdx
This commit is contained in:
Gustavo Valverde
2026-03-20 00:53:52 +00:00
parent c41fa044d4
commit 78bb334f0a
7 changed files with 211 additions and 1 deletions
+47
View File
@@ -592,3 +592,50 @@ If you need to use better fetch plugins, you can pass them to the `fetchPlugins`
This is only useful if you want to provide `hooks` like `useSession` and you want to listen to atoms and re-evaluate them when they change.
You can see how this is used in the built-in plugins.
## Plugin Extension Protocol
Some plugins act as **platforms** — they provide shared infrastructure that other plugins build on. The `oauth-provider` plugin is the primary example.
The **Plugin Extension Protocol** enables typed, declarative composition between plugins via two fields on `BetterAuthPlugin` (`extensions`, `dependencies`) and one `AuthContext` method (`getExtensions`):
### `extensions`
Contribute capabilities to a host plugin. Each key is a host plugin ID registered in `BetterAuthExtensionRegistry`, and the value must satisfy the host's extension contract:
```ts
{
id: "my-plugin",
extensions: {
"oauth-provider": {
grantTypes: { "urn:custom:grant": myHandler },
} satisfies OAuthProviderExtension,
},
}
```
TypeScript provides autocomplete for registered hosts and type-checking for the contract.
### `dependencies`
Declare which plugins your plugin requires. Validated at startup before any `init` runs:
```ts
{
id: "my-plugin",
dependencies: ["oauth-provider"],
}
```
### `getExtensions`
Host plugins call `ctx.getExtensions(hostId)` in their `init` to collect all typed contributions from guest plugins:
```ts
init(ctx) {
const extensions = ctx.getExtensions("oauth-provider");
// extensions is OAuthProviderExtension[]
}
```
See the <Link href="/docs/concepts/plugins">Plugins</Link> documentation for more details on creating plugins.
@@ -1908,4 +1908,90 @@ describe("base context creation", () => {
expect(ctx.hasPlugin("plugin-4")).toBe(false);
});
});
describe("getExtensions", () => {
it("should return empty array when no plugins have extensions", async () => {
const ctx = await initBase({
plugins: [{ id: "plain-plugin" }],
});
const result = ctx.getExtensions("nonexistent" as never);
expect(result).toEqual([]);
});
it("should collect extensions from multiple plugins", async () => {
const ext1 = { grantTypes: { "custom:a": () => {} } };
const ext2 = { grantTypes: { "custom:b": () => {} } };
const ctx = await initBase({
plugins: [
{
id: "plugin-a",
extensions: { "test-host": ext1 } as never,
},
{
id: "plugin-b",
extensions: { "test-host": ext2 } as never,
},
],
});
const result = ctx.getExtensions("test-host" as never);
expect(result).toHaveLength(2);
expect(result[0]).toBe(ext1);
expect(result[1]).toBe(ext2);
});
it("should skip plugins without extensions", async () => {
const ext = { metadata: () => ({}) };
const ctx = await initBase({
plugins: [
{ id: "no-ext" },
{
id: "with-ext",
extensions: { "test-host": ext } as never,
},
{ id: "also-no-ext" },
],
});
const result = ctx.getExtensions("test-host" as never);
expect(result).toHaveLength(1);
expect(result[0]).toBe(ext);
});
});
describe("plugin dependencies", () => {
it("should throw when a dependency is missing", async () => {
await expect(
initBase({
plugins: [
{
id: "child-plugin",
dependencies: ["missing-parent"],
},
],
}),
).rejects.toThrow(
'Plugin "child-plugin" requires plugin "missing-parent" which is not installed',
);
});
it("should pass when all dependencies are present", async () => {
const ctx = await initBase({
plugins: [
{ id: "parent-plugin" },
{
id: "child-plugin",
dependencies: ["parent-plugin"],
},
],
});
expect(ctx).toBeDefined();
expect(ctx.hasPlugin("child-plugin")).toBe(true);
});
it("should pass when plugin has no dependencies field", async () => {
const ctx = await initBase({
plugins: [{ id: "standalone-plugin" }],
});
expect(ctx).toBeDefined();
});
});
});
@@ -247,6 +247,13 @@ Most of the features of Better Auth will not work correctly.`,
const hasPluginFn = (id: string) => pluginIds.has(id);
const getExtensionsFn = (hostId: string) =>
(options.plugins ?? [])
.map(
(p) => (p.extensions as Record<string, unknown> | undefined)?.[hostId],
)
.filter((ext): ext is NonNullable<typeof ext> => ext != null);
const trustedOrigins = await getTrustedOrigins(options);
const trustedProviders = await getTrustedProviders(options);
@@ -398,6 +405,7 @@ Most of the features of Better Auth will not work correctly.`,
},
getPlugin: getPluginFn,
hasPlugin: hasPluginFn as never,
getExtensions: getExtensionsFn as never,
};
const initOrPromise = runPluginInit(ctx);
@@ -5,6 +5,7 @@ import type {
BetterAuthPlugin,
} from "@better-auth/core";
import { env } from "@better-auth/core/env";
import { BetterAuthError } from "@better-auth/core/error";
import { defu } from "defu";
import { createInternalAdapter } from "../db";
import { isPromise } from "../utils/is-promise";
@@ -13,6 +14,18 @@ import { getBaseURL, isDynamicBaseURLConfig } from "../utils/url";
export async function runPluginInit(context: AuthContext) {
let options = context.options;
const plugins = options.plugins || [];
const pluginIds = new Set(plugins.map((p) => p.id));
for (const plugin of plugins) {
for (const dep of plugin.dependencies ?? []) {
if (!pluginIds.has(dep)) {
throw new BetterAuthError(
`Plugin "${plugin.id}" requires plugin "${dep}" which is not installed. Add it to your plugins array.`,
);
}
}
}
const pluginTrustedOrigins: NonNullable<
BetterAuthOptions["trustedOrigins"]
>[] = [];
+29
View File
@@ -77,6 +77,23 @@ export type BetterAuthPluginRegistryIdentifier = keyof BetterAuthPluginRegistry<
unknown
>;
/**
* Extension contracts for platform plugins.
*
* Host plugins augment this interface to declare the shape
* of contributions they accept from other plugins.
*
* @example
* ```ts
* declare module "@better-auth/core" {
* interface BetterAuthExtensionRegistry {
* "oauth-provider": OAuthProviderExtension;
* }
* }
* ```
*/
export interface BetterAuthExtensionRegistry {}
export type GenericEndpointContext<
Options extends BetterAuthOptions = BetterAuthOptions,
> = EndpointContext<string, any, any, any, any, any, any, AuthContext<Options>>;
@@ -257,6 +274,18 @@ export type PluginContext<Options extends BetterAuthOptions> = {
hasPlugin: <ID extends BetterAuthPluginRegistryIdentifier | LiteralString>(
pluginId: ID,
) => ID extends InferPluginID<Options> ? true : boolean;
/**
* Collect all extension contributions targeting a host plugin.
*
* @example
* ```ts
* const extensions = ctx.getExtensions("oauth-provider");
* // extensions is OAuthProviderExtension[]
* ```
*/
getExtensions: <ID extends keyof BetterAuthExtensionRegistry>(
hostId: ID,
) => BetterAuthExtensionRegistry[ID][];
};
export type InfoContext = {
+1
View File
@@ -1,6 +1,7 @@
export type { StandardSchemaV1 } from "@standard-schema/spec";
export type {
AuthContext,
BetterAuthExtensionRegistry,
BetterAuthPluginRegistry,
BetterAuthPluginRegistryIdentifier,
GenericEndpointContext,
+27 -1
View File
@@ -8,7 +8,7 @@ import type { Migration } from "kysely";
import type { AuthMiddleware } from "../api";
import type { BetterAuthPluginDBSchema } from "../db";
import type { RawError } from "../utils/error-codes";
import type { AuthContext } from "./context";
import type { AuthContext, BetterAuthExtensionRegistry } from "./context";
import type { Awaitable, LiteralString } from "./helper";
import type { BetterAuthOptions } from "./init-options";
@@ -172,4 +172,30 @@ export type BetterAuthPlugin = BetterAuthPluginErrorCodePart & {
adapter?: {
[key: string]: (...args: any[]) => Awaitable<any>;
};
/**
* Typed contributions to host plugins.
*
* Each key is a host plugin ID registered in
* BetterAuthExtensionRegistry. The value must satisfy
* the host's extension contract.
*
* @example
* ```ts
* extensions: {
* "oauth-provider": {
* grantTypes: { "urn:custom:grant": myHandler },
* } satisfies OAuthProviderExtension,
* }
* ```
*/
extensions?:
| {
[K in keyof BetterAuthExtensionRegistry]?: BetterAuthExtensionRegistry[K];
}
| undefined;
/**
* Plugin IDs this plugin requires.
* Validated at startup before any init runs.
*/
dependencies?: string[] | undefined;
};