Skip to content

Commit 41a1135

Browse files
fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured (#21872)
Fixes #21846 Clause-②: no (narrowing) > **BREAKING** (`@objectstack/plugin-auth` `minor`, under the launch-window convention for accept-set narrowings): an external sign-in that used to link implicitly to an unverified local user, or re-link a provider the user had unlinked, is now refused with `error=account_not_linked`. The changeset carries the upgrade note and the ADR-0087 `not-required (no-migration-prescription)` disposition. ## What changes Implicit account linking on external sign-in (OAuth, OIDC, SSO) now requires the library's standard local-ownership condition. The platform identity provider keeps its documented exception, and a user's unlink is honoured. This follows the ruling recorded on the card (「算漏洞,收紧」). - **Every provider except the platform identity provider** (`objectstack-cloud`) links implicitly only to a local user whose email is verified. Otherwise the callback answers `error=account_not_linked`, the same code better-auth's own refusal produces. No link is written and the local row stays unverified, so the link no longer sets `emailVerified` on an unverified row. - **The platform identity provider** still links to an owner-seeded row (those rows are created with `emailVerified=false`). The exception applies only to its OAuth sign-in path (`source.method === 'oauth'`), so an SSO provider registered under the same id gets no exception. - **After a user unlinks a provider**, an implicit sign-in through it no longer re-creates the link. This applies to every provider. An explicit, session-authenticated `/link-social` is still allowed and ends the refusal. - **Operator override** via `account.accountLinking.requireLocalEmailVerified`: - unset (the default): the rules above; - `true`: also handed to better-auth, so the strict form applies to every provider, the platform one included; - `false`: turns off only the local-verification check; the unlink rule stays. ## Mechanism (better-auth 1.7.3, measured in the installed `dist/`) - **Why not the vendor flag.** `requireLocalEmailVerified` is one global boolean. It has no per-provider form, and `trustedProviders` does not relax it, so it cannot carry the platform exception. The vendor flag therefore stays `false` by default, and the requirement is enforced at the `user.validateUserInfo` seam. - **Where the gate runs.** `handleOAuthUserInfo` calls `user.validateUserInfo` with `action: 'link-account'` and the provider id, right before `linkAccount` and the `emailVerified` flip. Every implicit-link entry goes through it: the OAuth callback, id-token sign-in, one-tap, oauth-proxy and SSO. - **Explicit link.** An explicit link uses the same action. It is told apart by the server-written `link` in the parsed OAuth state (`getOAuthState()`), and only when that state's `link.userId` equals the user being linked. `generateState` writes `link` after the client's `additionalData`, so a client cannot forge it. - **Unlink record.** One `sys_verification` row per user and provider (`account-unlinked:<user id>:<provider id>`), created in `account.delete.before`, scoped to the `/unlink-account` path. A row is only ever created or deleted, never rewritten, so no write passes through a state with less protection and concurrent unlinks each keep their own row. A failed create is logged at `error` and rethrown, so the unlink fails and the provider stays linked (fail-closed). A landed link deletes only that provider's row, before the identity source is stamped; deleting the user deletes all of that user's rows by prefix. Records go through the database adapter. When a host configures better-auth `secondaryStorage`, the auth manager now also sets `verification.storeInDatabase: true`, so the record stays a database row behind the cache and survives eviction; hosts without `secondaryStorage` are unchanged. - **Deprecation.** better-auth marks the flag deprecated ("the gate will become unconditional"). On that upgrade the platform exception needs another carrier. The platform-provider end-to-end test fails on that bump, on purpose. New module: `packages/plugins/plugin-auth/src/implicit-account-linking.ts`. Wiring: `auth-manager.ts` (`validateUserInfo`, `account.accountLinking`, `composeDatabaseHooks`). Docs: `content/docs/permissions/sso.mdx` gains a "Linking to an existing account" section (the verified-email rule, the platform-provider exception, unlink and explicit re-link, the operator override, and what `trustedProviders` does and does not relax); `content/docs/permissions/authentication.mdx` points to it from the OAuth callback step. `auth-service.mdx` and `services-checklist.mdx`, also named by the docs drift check, say nothing about linking and are unchanged. ## Tests All at head `93ed0240e1` unless noted. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 121 files, 2538 passed, 10 skipped. `src/implicit-account-linking.test.ts`: 23 passed. Besides the decision table, the vendor config and the end-to-end OAuth round trips over the real better-auth pipeline (stubbed IdP, in-memory engine), it covers: - an unverified local row with an IdP-verified email is refused; a verified one links; the platform provider still links an unverified row; the operator opt-out works; - unlink, then implicit sign-in, is refused; an explicit link-social is then allowed and clears the record; - id-token sign-in through `/sign-in/social`: refused for an unverified user, linked for a verified one; - a client-supplied `link` in `additionalData` is refused; - an explicit link-social for an unverified user links without marking the email verified; - a generic OIDC provider configured by discovery: refused, then linked once verified; - a store fault during unlink: the unlink answers an error, the account is kept, no record is written; - deleting the user with no endpoint context removes the record; - a second unlink that hits a store fault keeps the first provider's record and refusal; - concurrent unlinks of two providers keep both records and both refusals; - with a host `secondaryStorage`, the record is a database row and survives evicting every verification cache entry; - the platform exception is refused for SSO (OIDC, SAML) sources and a missing method. - `pnpm --filter @objectstack/plugin-auth typecheck` (src, examples, `check:test-typecheck`): exit 0. - **Ablations** via `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: - gate wiring disabled: the unverified-row and unlink refusals fail; - explicit-link discrimination disabled: the explicit link after unlink fails; - platform exception removed: the pure and end-to-end platform cases fail; - the rethrow in `account.delete.before` removed: the store-fault test fails; - the delete-then-create rewrite reintroduced in the unlink hook: the second-unlink fault and concurrent-unlink tests fail. - **Gates.** `dispatch-gates --ran` at `83010a685e` (round 2): 105 derived, 104 run with exit 0, `check:dual-build-cjs-loads` NOT MEASURED (exit 3, it needs a whole-workspace build; declared to CI). Also exit 0: `check:adr-0087-registration`, `check:error-code-casing`, `check:durability-log-level`, `check:startup-registry-verdict`. - `scripts/engine-double-contract.pinned.json` is regenerated (`--write`) for the new test file's pinned double, a coverage-only addition. - **eslint, narrowed.** `eslint --no-inline-config --format json` over the 3 changed TS files: 0 errors, 0 warnings. The config has no type-aware linting, so this diff cannot change a verdict on an untouched file. ## Acceptance notes - **Refusal on the id-token and one-tap paths.** There, the new refusal answers 403 with code `account_not_linked`. The vendor's own refusal on those paths answers 401 `OAUTH_LINK_ERROR`. On the browser callback the two are identical (`error=account_not_linked`). No in-repo or objectui consumer reads either code. - **Unlink applies to the platform provider too.** A user who unlinks `objectstack-cloud` must re-link from account settings before platform SSO signs them in to that environment again. That follows the ruling's wording, and the platform exception is about the verification precondition only. - **Future better-auth bump.** When `requireLocalEmailVerified` becomes unconditional, the platform-provider exception needs a new carrier, for example the owner seed. Carrier: the PR that bumps better-auth to that minor, where the pinned end-to-end test turns red. - **Not run locally.** No dogfood real-boot run, because the box is loaded. The end-to-end tests drive the real better-auth pipeline in-process. - **Defence in depth.** The `link.userId` binding cannot be reached through a real flow today (the explicit-link callback always passes the linking user), so no test turns it red without a synthetic state. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e35895 commit 41a1135

7 files changed

Lines changed: 1247 additions & 25 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
fix(plugin-auth)!: implicit account linking on external sign-in requires the library's standard local-ownership condition; the platform identity provider keeps its documented exception; an unlink is honoured
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or config field is removed or renamed: the change narrows when an external sign-in links implicitly to an existing local user, and nothing an author wrote needs rewriting. The one config key it reads, account.accountLinking.requireLocalEmailVerified, keeps its name and gains an explicit opt-out meaning. -->
10+
11+
**BREAKING for deployments that relied on external sign-in (OAuth, OIDC, SSO) linking implicitly to a local user whose email is not verified.**
12+
13+
**What changed.**
14+
15+
- An external sign-in links implicitly to an existing local user only when that local user's email is verified. Otherwise the sign-in is refused with `error=account_not_linked`, the same code better-auth's own refusal produces. No link is written and the local user stays unverified. A verified local user links as before.
16+
- The platform's own identity provider (`objectstack-cloud`) keeps its documented exception and still links to an unverified local user, because it seeds the environment owner's row without a mailbox round-trip.
17+
- After a user unlinks a provider, an implicit sign-in through it no longer links the identity again, for any provider. An explicit, signed-in link from account settings (`/link-social`) is still allowed and ends the refusal. If the unlink cannot be recorded, the unlink itself is refused and the provider stays linked. Deleting a user removes the user's unlink records.
18+
- A deployment that passes `secondaryStorage` to the auth plugin now also keeps verification values in the database (`verification.storeInDatabase: true`). The cache still fronts them. This keeps the unlink records durable when the cache evicts entries. Deployments without `secondaryStorage` are unchanged.
19+
- `account.accountLinking.requireLocalEmailVerified` now reads as follows. Unset (the default) means the rules above. `true` applies the strict check to every provider, including `objectstack-cloud`. `false` turns off only the local-verification check and keeps the unlink rule.
20+
21+
**What to do after upgrading.**
22+
23+
- A user refused this way signs in with their existing method, then links the provider from account settings, or verifies their email first.
24+
- To let unverified local users link implicitly again, set `account.accountLinking.requireLocalEmailVerified: false`. Before you do, read the library's warning about account takeover.
25+
- If you pass `secondaryStorage`: verification values written to the cache alone before the upgrade (password-reset links, one-time codes, magic links and email-verification links that were in flight at deploy time) can no longer be consumed afterwards. Users who hit this request a fresh link or code once.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,12 @@ window.location.href = data.url ?? data.data.url;
527527

528528
Better-Auth automatically handles the OAuth callback at `/api/v1/auth/callback/google` and redirects the user back to your application with a session.
529529

530+
When the provider's email matches an existing local account that is not linked to this
531+
provider yet, the sign-in links to that account only if its email is verified. Otherwise
532+
the callback redirects with `error=account_not_linked`. The platform identity provider
533+
(`objectstack-cloud`) is the one exception, and an unlinked provider never re-links
534+
itself. See [Linking to an existing account](/docs/permissions/sso#linking-to-an-existing-account).
535+
530536
---
531537

532538
## Enterprise Authentication Hardening (ADR-0069)

‎content/docs/permissions/sso.mdx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,35 @@ The Console `/login` and `/register` pages will now show a button for each enabl
385385
3. Browser redirects to the provider's authorization endpoint.
386386
4. Provider redirects back; better-auth validates the OIDC token and creates a session.
387387

388+
## Linking to an existing account
389+
390+
A social, OIDC or enterprise SSO sign-in may arrive with an email that already belongs to a
391+
local account, one that is not linked to this provider yet. The platform then links the
392+
identity to that account automatically (an *implicit* link) only under these rules:
393+
394+
- **The local account's email must be verified.** If it is not, no link is written and the
395+
callback redirects with `error=account_not_linked`. The user signs in with their existing
396+
method and links the provider from account settings, or verifies their email first.
397+
- **The platform identity provider is the exception.** `objectstack-cloud` links to an
398+
unverified local account, because it creates the environment owner's account itself,
399+
without a mailbox round-trip.
400+
- **An unlink is honoured.** After a user unlinks a provider, signing in with that provider
401+
no longer re-links it, for any provider, `objectstack-cloud` included. The user links it
402+
again explicitly from account settings (`POST /api/v1/auth/link-social` while signed in),
403+
which also ends the refusal. If the unlink cannot be recorded, the unlink itself fails and
404+
the provider stays linked.
405+
406+
An explicit link from account settings does not depend on whether the signed-in user's
407+
email is verified. It still requires the provider to vouch for the email (a trusted
408+
provider or an `email_verified` claim) and the email to match the account's.
409+
410+
`account.accountLinking.requireLocalEmailVerified` in the auth plugin config changes the first
411+
rule. Unset (the default) gives the rules above. `true` applies the verified-email
412+
requirement to every provider, `objectstack-cloud` included. `false` turns the requirement
413+
off, which the library warns is an account-takeover risk; the unlink rule still applies.
414+
`account.accountLinking.trustedProviders` relaxes only the provider-side `email_verified`
415+
claim and never the local requirement.
416+
388417
---
389418

390419
## Notes

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 150 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import {
3030
type VerificationDeclarant,
3131
} from './audience-posture.js';
3232
import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js';
33+
import {
34+
PLATFORM_IDP_PROVIDER_ID,
35+
clearUnlinkTombstone,
36+
clearUserUnlinkTombstones,
37+
recordUnlinkTombstone,
38+
type LinkingAuthContext,
39+
refuseImplicitAccountLink,
40+
} from './implicit-account-linking.js';
3341
import type { IDataEngine } from '@objectstack/core';
3442
// [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2).
3543
// `auth-manager` used to re-derive that standing itself, in two spellings
@@ -1590,47 +1598,64 @@ export class AuthManager {
15901598
// by vendor construction. One seam, one owner — see
15911599
// `audience-posture.ts` for the decision semantics, the pinned
15921600
// domain-matching rules, and the creation-class table.
1593-
validateUserInfo: (data: any, ctx: any) => this.validateAudienceAdmission(data, ctx),
1601+
//
1602+
// The same seam also carries the implicit-account-linking gate: the
1603+
// vendor calls it with `action: 'link-account'` right before an
1604+
// implicit link is written — see `implicit-account-linking.ts`. The
1605+
// audience gate judges only `create-user`, so the two never answer
1606+
// the same call.
1607+
validateUserInfo: async (data: any, ctx: any) =>
1608+
(await refuseImplicitAccountLink(data, ctx, {
1609+
requireLocalEmailVerified: this.implicitLinkRequiresLocalEmailVerified(),
1610+
resolveContext: () => this.linkingContext(),
1611+
logInfo: (m, meta) => this.config.logger?.info?.(m, meta),
1612+
})) ?? this.validateAudienceAdmission(data, ctx),
15941613
},
15951614
account: {
15961615
...AUTH_ACCOUNT_CONFIG,
1597-
// Allow OIDC/OAuth callbacks to implicitly link the incoming
1598-
// identity to a pre-existing local user when the emails match.
1599-
//
1600-
// ObjectStack's platform SSO ("objectstack-cloud" provider) is the
1601-
// canonical case: cloud is the IdP for every project, so a user
1602-
// arriving via SSO is — by construction — the same person who was
1603-
// auto-seeded as the project owner when the project was created.
1604-
// Without trusting the provider, better-auth's safety check rejects
1605-
// the link with `error=account_not_linked` because the seeded user
1606-
// row has `emailVerified=false` (no actual verification ever runs
1607-
// in the IdP-mediated flow). See packages/plugins/plugin-auth/
1608-
// node_modules/better-auth/dist/oauth2/link-account.mjs:22.
1616+
// Implicit account linking: an OAuth / OIDC / SSO sign-in whose
1617+
// identity is not linked yet, but whose email matches an existing
1618+
// local user, links to that user. The rules — every provider needs a
1619+
// VERIFIED local row, except the platform's own cloud IdP
1620+
// (`PLATFORM_IDP_PROVIDER_ID`, whose owner-seeded rows are born
1621+
// `emailVerified=false`), and a user's unlink is honoured — are
1622+
// enforced in `refuseImplicitAccountLink` (the `validateUserInfo`
1623+
// seam above). Why there, and why the vendor flag below is pinned
1624+
// `false` by default: `implicit-account-linking.ts`, module header.
16091625
//
16101626
// Custom-deployment consumers can extend the trusted set via
16111627
// `config.account.accountLinking.trustedProviders`; we always
16121628
// include `objectstack-cloud` because it is the platform IdP.
1629+
// Trust relaxes only the IdP-side `email_verified` clause; it never
1630+
// relaxes the local-verification requirement.
16131631
accountLinking: {
16141632
enabled: true,
1615-
// better-auth's account-linking gate has TWO independent clauses
1616-
// (see link-account.mjs:22). Trusting the provider only satisfies
1617-
// the first clause; the second — `requireLocalEmailVerified &&
1618-
// !dbUser.user.emailVerified` — still blocks linking when the
1619-
// pre-existing local user row has `emailVerified=false` (the
1620-
// default for owner-seeded rows). Disabling the local-email gate
1621-
// is safe here because the OAuth side is what we actually trust:
1622-
// the incoming identity was verified by the IdP. Consumers who
1623-
// need the stricter behavior can override via config.
1624-
requireLocalEmailVerified: false,
16251633
...((this.config as any)?.account?.accountLinking ?? {}),
1634+
// better-auth's flag is ONE global boolean with no per-provider
1635+
// form, so it cannot carry the cloud exception: `true` would refuse
1636+
// the cloud owner row before our gate runs. It is therefore `false`
1637+
// unless the operator explicitly asked for the strict form for
1638+
// EVERY provider (`true`), and the requirement itself is enforced
1639+
// by our gate (`implicitLinkRequiresLocalEmailVerified`).
1640+
requireLocalEmailVerified:
1641+
(this.config as any)?.account?.accountLinking?.requireLocalEmailVerified === true,
16261642
trustedProviders: Array.from(new Set([
1627-
'objectstack-cloud',
1643+
PLATFORM_IDP_PROVIDER_ID,
16281644
...((this.config as any)?.account?.accountLinking?.trustedProviders ?? []),
16291645
])),
16301646
},
16311647
},
16321648
verification: {
16331649
...AUTH_VERIFICATION_CONFIG,
1650+
// A host `secondaryStorage` would otherwise make the cache the ONLY
1651+
// home of every verification value (better-auth drops the
1652+
// `verification` model from the schema), and the implicit-linking
1653+
// unlink records (`implicit-account-linking.ts`) must be durable: an
1654+
// evicted record re-opens implicit re-linking without a sound. With
1655+
// `storeInDatabase` every verification value is a `sys_verification`
1656+
// row — exactly what it is when no `secondaryStorage` is configured,
1657+
// ObjectStack's default — and the cache only fronts it.
1658+
...(this.config.secondaryStorage ? { storeInDatabase: true } : {}),
16341659
},
16351660

16361661
// Social / OAuth providers
@@ -4524,6 +4549,31 @@ export class AuthManager {
45244549
else logger?.warn?.(message, meta);
45254550
}
45264551

4552+
/**
4553+
* The effective local-verification requirement for an implicit account
4554+
* link — `true` unless the operator explicitly set
4555+
* `account.accountLinking.requireLocalEmailVerified: false`. See
4556+
* `implicit-account-linking.ts` for the rules and the operator override.
4557+
*/
4558+
private implicitLinkRequiresLocalEmailVerified(): boolean {
4559+
return (this.config as any)?.account?.accountLinking?.requireLocalEmailVerified !== false;
4560+
}
4561+
4562+
/**
4563+
* better-auth's auth context read off the auth instance — the store for the
4564+
* implicit-linking hooks when a call carries no endpoint context (a
4565+
* server-side `auth.api.*` call or an internal-adapter write).
4566+
*/
4567+
private async linkingContext(): Promise<LinkingAuthContext | undefined> {
4568+
try {
4569+
const auth: any = await this.getOrCreateAuth();
4570+
const context = await auth?.$context;
4571+
return context?.adapter && context?.internalAdapter ? (context as LinkingAuthContext) : undefined;
4572+
} catch {
4573+
return undefined;
4574+
}
4575+
}
4576+
45274577
/** OAuth providerIds that are OPERATOR-REGISTERED identity authorities (enterprise `oidcProviders`, incl. the cloud platform IdP). */
45284578
private enterpriseOAuthProviderIds(): ReadonlySet<string> {
45294579
const ids = new Set<string>();
@@ -7376,7 +7426,27 @@ export class AuthManager {
73767426
private composeDatabaseHooks(
73777427
host?: BetterAuthOptions['databaseHooks'],
73787428
): BetterAuthOptions['databaseHooks'] {
7379-
const stamp = (account: any, ctx: any) => this.stampIdentitySource(account, ctx);
7429+
// A landed link ends a standing unlink record (implicit-account-linking.ts,
7430+
// rule 3). While the record stands an implicit link is refused, so a link
7431+
// that lands here is an explicit one. Failure to clear is functional (the
7432+
// user sees a refusal on their next implicit sign-in and can re-link), so
7433+
// it is reported at `warn` and never fails the link. It runs FIRST, ahead
7434+
// of the identity-source stamp, so a stamp failure can never leave a
7435+
// landed link still refused.
7436+
const resolveLinkingAdapter = () => this.linkingContext();
7437+
const clearUnlink = async (account: any, ctx: any) => {
7438+
try {
7439+
await clearUnlinkTombstone(account, ctx, resolveLinkingAdapter);
7440+
} catch (e) {
7441+
this.config.logger?.warn?.('[auth] could not clear the unlink record after a link', {
7442+
error: (e as Error)?.message,
7443+
});
7444+
}
7445+
};
7446+
const stamp = async (account: any, ctx: any) => {
7447+
await clearUnlink(account, ctx);
7448+
await this.stampIdentitySource(account, ctx);
7449+
};
73807450
const hostAccountAfter = (host as any)?.account?.create?.after;
73817451
const after = hostAccountAfter
73827452
? async (account: any, ctx: any) => {
@@ -7385,6 +7455,53 @@ export class AuthManager {
73857455
}
73867456
: stamp;
73877457

7458+
// A user's unlink is recorded so the provider cannot re-link implicitly
7459+
// (implicit-account-linking.ts, rule 3) — BEFORE the account row goes, so
7460+
// that a record which cannot be written aborts the unlink: a throw from a
7461+
// `delete.before` hook propagates out of better-auth's delete, the unlink
7462+
// answers an error and the identity stays linked. Fail closed: an unlink
7463+
// that answered success without its record would leave the provider free
7464+
// to re-link on the next sign-in. Reported at `error`, once per refusal.
7465+
// A host `delete.before` runs first; its `false` (abort) is honoured
7466+
// before anything is recorded.
7467+
const hostAccountDeleteBefore = (host as any)?.account?.delete?.before;
7468+
const accountDeleteBefore = async (account: any, ctx: any) => {
7469+
const result = hostAccountDeleteBefore ? await hostAccountDeleteBefore(account, ctx) : undefined;
7470+
if (result === false) return false;
7471+
try {
7472+
await recordUnlinkTombstone(account, ctx, resolveLinkingAdapter);
7473+
} catch (e) {
7474+
this.audienceLogError(
7475+
'[auth] unlink refused: its record could not be written, so the provider stays linked. ' +
7476+
'Without the record the provider could re-link this account implicitly on its next sign-in. ' +
7477+
'Check the auth store (sys_verification) is writable, then unlink again.',
7478+
{ providerId: account?.providerId, error: (e as Error)?.message },
7479+
);
7480+
throw e;
7481+
}
7482+
return result;
7483+
};
7484+
7485+
// A deleted user leaves no unlink record behind (implicit-account-linking.ts).
7486+
// A record that outlives its user is inert — a new user never has the
7487+
// deleted user's id — so a failure is reported at `warn` and never fails
7488+
// the deletion.
7489+
const clearUserUnlinks = async (user: any, ctx: any) => {
7490+
try {
7491+
await clearUserUnlinkTombstones(user, ctx, resolveLinkingAdapter);
7492+
} catch (e) {
7493+
this.config.logger?.warn?.('[auth] could not clear the unlink record of a deleted user', {
7494+
error: (e as Error)?.message,
7495+
});
7496+
}
7497+
};
7498+
const hostUserDeleteAfter = (host as any)?.user?.delete?.after;
7499+
const userDeleteAfter = async (user: any, ctx: any) => {
7500+
const result = hostUserDeleteAfter ? await hostUserDeleteAfter(user, ctx) : undefined;
7501+
await clearUserUnlinks(user, ctx);
7502+
return result;
7503+
};
7504+
73887505
// ADR-0093 D9 — default active-org on session create. Without it, a user
73897506
// with memberships logs in with `activeOrganizationId = null`: better-auth
73907507
// org endpoints can't resolve an active org (single-org invite dead-end)
@@ -7599,6 +7716,10 @@ export class AuthManager {
75997716
...((host as any)?.account?.create ?? {}),
76007717
after,
76017718
},
7719+
delete: {
7720+
...((host as any)?.account?.delete ?? {}),
7721+
before: accountDeleteBefore,
7722+
},
76027723
},
76037724
user: {
76047725
...((host as any)?.user ?? {}),
@@ -7607,6 +7728,10 @@ export class AuthManager {
76077728
before: userBefore,
76087729
after: userAfter,
76097730
},
7731+
delete: {
7732+
...((host as any)?.user?.delete ?? {}),
7733+
after: userDeleteAfter,
7734+
},
76107735
},
76117736
session: {
76127737
...((host as any)?.session ?? {}),

0 commit comments

Comments
 (0)