Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/auth-database-hooks-middleware-claim-corrected.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 48 additions & 14 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,47 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {

/**
* 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<IDataEngine>('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'];

Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,15 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
* 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'];
}
Expand Down
Loading