mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
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:
@@ -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"]
|
||||
>[] = [];
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
export type {
|
||||
AuthContext,
|
||||
BetterAuthExtensionRegistry,
|
||||
BetterAuthPluginRegistry,
|
||||
BetterAuthPluginRegistryIdentifier,
|
||||
GenericEndpointContext,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user