diff --git a/.changeset/auth-database-hooks-middleware-claim-corrected.md b/.changeset/auth-database-hooks-middleware-claim-corrected.md new file mode 100644 index 0000000000..5e051d670c --- /dev/null +++ b/.changeset/auth-database-hooks-middleware-claim-corrected.md @@ -0,0 +1,12 @@ +--- +--- + +docs(plugin-auth): the `databaseHooks` doc comment no longer claims better-auth's adapter bypasses the ObjectQL middleware chain (#4802). The option's JSDoc in `auth-manager.ts` (and its two sibling copies — the wiring comment beside `composeDatabaseHooks`, and `AuthPluginOptions.databaseHooks` in `auth-plugin.ts`) justified "use `databaseHooks`, not an ObjectQL middleware" with a mechanism claim that no longer holds: *better-auth's adapter goes through `dataEngine` directly, bypassing the `ql.registerMiddleware` chain*. + +Re-verified hop by hop against `main`: `ObjectQLPlugin` registers **one** engine instance under both service names (`registerService('objectql', this.ql)` and `registerService('data', this.ql)`, and nothing else in the repo registers `data`); `AuthPlugin` passes exactly that instance to `createObjectQLAdapterFactory`; the adapter writes with a plain `dataEngine.insert(objectName, …)` — there is no bypass or skip-middleware option to pass; and `ObjectQL.insert()` wraps its body in `executeWithMiddleware()`, whose only filter is the object name. So `ql.registerMiddleware(fn, { object: 'sys_user' })` **does** fire for better-auth's writes, and so do the engine's `beforeInsert`/`afterInsert` hooks — the SCIM identity-source stamp in `auth-plugin.ts` is built on precisely that. + +The **rule is unchanged** — user-lifecycle invariants still belong in `user.create.after`, not in a `sys_user` middleware — but the reason is now the one that is actually true: **ADR-0093 D2**, one owner for the invariant on the one seam every creation path already flows through (self-signup, admin create-user, import, SSO JIT). The narrower fact that survives is written down instead of the false one: adapter writes carry `context.isSystem: true` (`withSystemContext`, pinned by `objectql-adapter.test.ts`), so every *authorization* middleware — security, sharing, the ADR-0092 identity write guard — early-returns by design; a middleware that gates on `isSystem` sees nothing, one that does not, runs. + +The stale sentence is **refuted in place rather than deleted**, because it had been copied into cloud's agent-facing docs and had already killed the middleware option in two rounds of design work there (cloud#1012, handed over as cloud#1022). A reader arriving from one of those copies needs to see the claim named and corrected; a silent deletion would leave them assuming the framework comment is the stale one. + +Comments only — no runtime behaviour changes, nothing released. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index eec59b46b5..162bf4af81 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -476,16 +476,47 @@ export interface AuthManagerOptions extends Partial { /** * Pass-through to better-auth's `databaseHooks` option. better-auth fires - * these around its own adapter writes (e.g. when `genericOAuth` creates - * a JIT user during SSO login), which the kernel-level ObjectQL - * middleware does NOT observe — better-auth's adapter goes through - * `dataEngine` directly, bypassing the `ql.registerMiddleware` chain. + * these around its own adapter writes, including paths that never reach an + * HTTP route of ours (e.g. `genericOAuth` JIT-creating a user during SSO + * login). * - * The platform uses this to attach a `user.create.after` hook that - * auto-provisions a personal organization for every newly-created user - * (mirroring what SecurityPlugin's middleware does for direct - * ObjectQL inserts) so SSO-arriving users don't land on the empty - * "create organization" screen. + * **Use this seam — not an ObjectQL middleware on `sys_user` — to react to + * user creation.** The reason is ADR-0093 D2: an invariant over the user + * lifecycle gets exactly ONE owner, composed into `user.create.after` + * (`reconcile-membership.ts`), because that is the single seam every + * creation path already flows through (self-signup, admin create-user, + * import, SSO JIT). Re-deriving the invariant per creation path — or in a + * parallel data-layer middleware — is precisely the shape D2 eliminated. + * The platform's own use is a `user.create.after` hook that provisions a + * personal/default organization so SSO-arriving users don't land on the + * empty "create organization" screen. + * + * **Corrected mechanism (#4802).** This comment used to justify the rule by + * asserting that better-auth's adapter "goes through `dataEngine` directly, + * bypassing the `ql.registerMiddleware` chain". That is **not** true, and + * the claim was copied widely enough (framework + cloud) to keep producing + * wrong architectural conclusions, so it is refuted here rather than quietly + * deleted. Verified hop by hop: + * + * - `objectql/src/plugin.ts` registers ONE instance under both names — + * `registerService('objectql', this.ql)` and `registerService('data', + * this.ql)`; nothing else in the repo registers `data`. + * - `auth-plugin.ts` takes `ctx.getService('data')` and hands + * that same instance to {@link createObjectQLAdapterFactory}. + * - `objectql-adapter.ts` writes with plain `dataEngine.insert(objectName, + * …)` — there is no bypass/skip-middleware option to pass. + * - `ObjectQL.insert()` (`objectql/src/engine.ts`) wraps its body in + * `executeWithMiddleware()`, whose only filter is the object name. + * + * So `ql.registerMiddleware(fn, { object: 'sys_user' })` **does** fire for + * better-auth's writes, and so do the engine's `beforeInsert`/`afterInsert` + * lifecycle hooks (the SCIM identity-source stamp in `auth-plugin.ts` relies + * on exactly that). What remains true is narrower and worth knowing: adapter + * writes carry `context.isSystem: true` (`withSystemContext`, pinned by + * `objectql-adapter.test.ts`), and every authorization middleware — + * security, sharing, the ADR-0092 identity write guard — early-returns on + * `isSystem` by design. A middleware that gates on `isSystem` therefore sees + * nothing; one that does not, runs. */ databaseHooks?: BetterAuthOptions['databaseHooks']; @@ -1001,11 +1032,14 @@ export class AuthManager { // better-auth plugins — registered based on AuthPluginConfig flags plugins, - // Database hooks (fired by better-auth's adapter writes — these run - // for SSO JIT-provisioning too, unlike kernel-level ObjectQL - // middleware which better-auth's adapter bypasses). The framework's - // identity-source stamp (`account.create.after`) is always composed in, - // preserving any host-supplied hooks. + // Database hooks (fired by better-auth's adapter writes — the ONE seam + // every user-creation path flows through, SSO JIT-provisioning included). + // ADR-0093 D2 makes that seam the single owner of the user-lifecycle + // invariants; see the `databaseHooks` option doc for why, and for the + // #4802 correction of the "adapter bypasses ObjectQL middleware" claim + // this comment used to carry. The framework's identity-source stamp + // (`account.create.after`) is always composed in, preserving any + // host-supplied hooks. databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks), // Bootstrap bypass for `disableSignUp`. The first-run owner wizard diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 72b5e1a165..43e4764254 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -161,10 +161,15 @@ export interface AuthPluginOptions extends Partial { * Pass-through to better-auth's `databaseHooks` option. Used by * platform consumers (objectos kernel) to attach a * `user.create.after` hook that auto-provisions a personal - * organization for JIT-created SSO users — better-auth's adapter - * bypasses kernel-level ObjectQL middleware, so this is the only - * hook point that fires for every user creation path (email signup, - * social/OIDC sign-in, admin-created accounts). + * organization for JIT-created SSO users. + * + * This is the seam to use because it is the ONE hook point every user + * creation path flows through (email signup, social/OIDC sign-in, + * admin-created accounts, SSO JIT) and ADR-0093 D2 gives such invariants a + * single owner there. It is NOT because better-auth's adapter escapes the + * data layer: it writes through the same ObjectQL instance and its + * middleware/lifecycle-hook chain does run — see + * `AuthManagerOptions.databaseHooks` for the hop-by-hop correction (#4802). */ databaseHooks?: BetterAuthOptions['databaseHooks']; }