From 78bb334f0a2ba80654c8f3d5cad5a3b7ffdf1a10 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Wed, 18 Mar 2026 18:01:36 +0000 Subject: [PATCH] feat(core): add plugin extension registry and dependency validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/content/docs/concepts/plugins.mdx | 47 ++++++++++ .../src/context/create-context.test.ts | 86 +++++++++++++++++++ .../better-auth/src/context/create-context.ts | 8 ++ packages/better-auth/src/context/helpers.ts | 13 +++ packages/core/src/types/context.ts | 29 +++++++ packages/core/src/types/index.ts | 1 + packages/core/src/types/plugin.ts | 28 +++++- 7 files changed, 211 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx index abeed84217..bec6db6265 100644 --- a/docs/content/docs/concepts/plugins.mdx +++ b/docs/content/docs/concepts/plugins.mdx @@ -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 Plugins documentation for more details on creating plugins. diff --git a/packages/better-auth/src/context/create-context.test.ts b/packages/better-auth/src/context/create-context.test.ts index b88646aecc..d13f49bf0c 100644 --- a/packages/better-auth/src/context/create-context.test.ts +++ b/packages/better-auth/src/context/create-context.test.ts @@ -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(); + }); + }); }); diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts index 2680c65414..4d2869e082 100644 --- a/packages/better-auth/src/context/create-context.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -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 | undefined)?.[hostId], + ) + .filter((ext): ext is NonNullable => 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); diff --git a/packages/better-auth/src/context/helpers.ts b/packages/better-auth/src/context/helpers.ts index 3daa641fdb..1120189326 100644 --- a/packages/better-auth/src/context/helpers.ts +++ b/packages/better-auth/src/context/helpers.ts @@ -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"] >[] = []; diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 14718a7997..8f01033e57 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -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>; @@ -257,6 +274,18 @@ export type PluginContext = { hasPlugin: ( pluginId: ID, ) => ID extends InferPluginID ? true : boolean; + /** + * Collect all extension contributions targeting a host plugin. + * + * @example + * ```ts + * const extensions = ctx.getExtensions("oauth-provider"); + * // extensions is OAuthProviderExtension[] + * ``` + */ + getExtensions: ( + hostId: ID, + ) => BetterAuthExtensionRegistry[ID][]; }; export type InfoContext = { diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 3545c194ea..2df50cfa96 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,6 +1,7 @@ export type { StandardSchemaV1 } from "@standard-schema/spec"; export type { AuthContext, + BetterAuthExtensionRegistry, BetterAuthPluginRegistry, BetterAuthPluginRegistryIdentifier, GenericEndpointContext, diff --git a/packages/core/src/types/plugin.ts b/packages/core/src/types/plugin.ts index 8f0f52a525..4ca364d34b 100644 --- a/packages/core/src/types/plugin.ts +++ b/packages/core/src/types/plugin.ts @@ -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; }; + /** + * 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; };