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
8 changes: 6 additions & 2 deletions packages/plugins/plugin-auth/src/adopt-membership.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,8 +36,12 @@
* ## Why the seam is HERE, at the better-auth → ObjectQL adapter
*
* The three hook seams the framework already owns on this route all run at the
* wrong moment or with the wrong reach, verified against better-auth
* `1.7.0-rc.2`:
* wrong moment or with the wrong reach. Measured 2026-08-20 against the
* installed better-auth `1.7.1`, whose
* `dist/plugins/organization/routes/crud-invites.mjs` still calls
* `adapter.createMember(...)` unconditionally at `:324` and, in the `.catch`
* at `:332-339`, rolls the invitation back to `"pending"` and rethrows —
* `beforeAcceptInvitation` fires ahead of all of it at `:280`:
*
* - `organizationHooks.beforeAcceptInvitation` fires *before* `createMember`
* and can only throw or mutate. The one mutation that would make the insert
Expand Down
35 changes: 24 additions & 11 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1081,15 +1081,20 @@ export class AuthManager {
// one adds only the notice. So `sendChangeEmailConfirmation` stays
// absent above — and that is a measured decision, not an omission:
//
// `user.changeEmail` in better-auth 1.7.0-rc.2 declares EXACTLY three
// members (`@better-auth/core/src/types/init-options.ts:946-971`):
// `enabled`, `sendChangeEmailConfirmation`, and
// `updateEmailWithoutVerification`. There is NO notify-only hook, and
// `sendChangeEmailConfirmation` is not one: in `update-user.mjs:457`
// it becomes `canSendConfirmation`, and the branch at :495 RETURNS
// right after invoking it — the new address is never mailed until the
// old one clicks. Setting it is therefore structurally the approval
// gate the ruling refuses, not a way to notify.
// `user.changeEmail` declares EXACTLY three members: `enabled`,
// `sendChangeEmailConfirmation`, and `updateEmailWithoutVerification`.
// Measured 2026-08-20 against the installed
// `@better-auth/core@1.7.1`, in the file the package actually SHIPS —
// `dist/types/init-options.d.mts:835-857`. (The previous stamp cited
// `src/types/init-options.ts:946-971`; no `src/` is published, so
// that reference could not be checked by anyone reading it.)
// There is NO notify-only hook, and
// `sendChangeEmailConfirmation` is not one: in better-auth 1.7.1's
// `dist/api/routes/update-user.mjs:457` it becomes
// `canSendConfirmation`, and the branch opening at `:496` RETURNS at
// `:505` right after invoking it — the new address is never mailed
// until the old one clicks. Setting it is therefore structurally the
// approval gate the ruling refuses, not a way to notify.
//
// The notice is consequently sent by the framework, from the global
// `after` hook on `/change-email` (search `__osChangeEmailFrom`), using
Expand DownExpand Up@@ -3603,8 +3608,16 @@ export class AuthManager {
// [#7724] A subject-erasure request is ONE unit of work, and better-auth
// does not treat it as one: `internalAdapter.deleteUser` deletes the
// sessions, then the accounts, then the user, in three unrelated adapter
// calls with no transaction (verified in better-auth 1.7.0-rc.2 —
// `dist/db/internal-adapter.mjs` mentions no transaction at all). Anything
// calls with no transaction. Measured 2026-08-20 against the installed
// better-auth 1.7.1: `deleteUser` at `dist/db/internal-adapter.mjs:233-247`
// is three separate `deleteManyWithHooks` / `deleteWithHooks` calls
// (session `:235`, account `:239`, user `:243`), and the string
// "transaction" does not occur anywhere in that file — a zero result that
// means something because "deleteManyWithHooks" occurs 8 times in it, so
// the search does reach the text. (The session call is now conditional on
// `!secondaryStorage || storeSessionInDatabase`; ObjectStack wires no
// `secondaryStorage` — deliberately, see `session-tombstone.ts` — so all
// three fire here.) Anything
// that refuses the LAST of those three leaves the first two committed: the
// credential rows are gone, the `sys_user` row is not, and the deployment
// is left with an identity that still occupies the org roster and can no
Expand Down
26 changes: 25 additions & 1 deletion packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -914,7 +914,7 @@ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
* Each row is an external OIDC/SAML IdP this environment federates login to
* (the relying-party side — ADR-0024's OPEN per-env SSO mechanism). The
* protocol detail lives in JSON blobs (`oidcConfig` / `samlConfig`); the model
* itself is thin. Mirrors @better-auth/sso@1.6.20's `BaseSSOProvider`.
* itself is thin.
*
* | camelCase (better-auth) | snake_case (ObjectStack) |
* |:------------------------|:-------------------------|
Expand All@@ -924,6 +924,30 @@ export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
* | userId | user_id |
* | organizationId | organization_id |
* | issuer / domain | (same name — no remap) |
* | domainVerified | domain_verified |
*
* ## Coverage, measured 2026-08-20 against `@better-auth/sso@1.7.1`
*
* The previous note here said only "Mirrors `@better-auth/sso@1.6.20`'s
* `BaseSSOProvider`" — a field-surface claim about a version two minors behind
* the installed one, which nobody had re-checked. Re-measured by resolving the
* plugin's real model the way the adapter does (`field.fieldName ?? key`) over
* `getAuthTables({ plugins: [sso()] }).ssoProvider.fields`:
*
* - `sso()` declares 7 fields — `issuer`, `oidcConfig`, `samlConfig`,
* `userId`, `providerId`, `organizationId`, `domain` — exactly the members
* of the shipped `BaseSSOProvider` type
* (`dist/index-CZytzKv6.d.mts:189-197`).
* - `sso({ domainVerification: { enabled: true } })` — the shape
* `OS_SSO_DOMAIN_VERIFICATION` turns on — adds an 8th, `domainVerified`.
* - Every one of those 8 resolves to a column `sys_sso_provider` declares.
* Nothing in the map is orphaned, and nothing in the model is unmapped.
*
* ⚠️ Unlike the core models, this mapping has **no parity gate**:
* `better-auth-schema-parity.test.ts` deliberately passes `getAuthTables()` no
* `sso` plugin, so an upstream field added to `ssoProvider` would land here
* silently. Until that changes, re-run the resolution above by hand when the
* `@better-auth/sso` pin moves — the check is one `getAuthTables` call.
*/
export const AUTH_SSO_PROVIDER_SCHEMA = {
modelName: 'sys_sso_provider',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,8 +504,10 @@ describe('#8019 — change-email notifies the previous address without gating on

it('keeps `sendChangeEmailConfirmation` OFF — the notice is not the gate in disguise', async () => {
// ⛔ Ruling edge 1: #7735's 「策略按 better-auth 常规」 still governs the
// CONFIRMATION option, and in better-auth 1.7.0-rc.2 that option is not a
// notifier — `update-user.mjs` returns immediately after invoking it, so
// CONFIRMATION option, and that option is not a notifier — measured
// 2026-08-20 against the installed better-auth 1.7.1, whose
// `dist/api/routes/update-user.mjs` opens the branch at `:496` and returns
// `ctx.json({ status: true })` at `:505`, immediately after invoking it, so
// the NEW address is never mailed until the OLD one clicks. Setting it
// would silently convert this card's notification into the approval gate
// the ruling refuses, and every assertion above would still pass. Read off
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,8 @@
* better-auth's `bearer()` plugin authenticates a request by OVERWRITING the
* request's session cookie with the bearer token (a before-hook calling
* `setRequestCookie(headers, authCookies.sessionToken.name, decodedToken)` —
* `dist/plugins/bearer/index.mjs`, verified in 1.7.0-rc.2). The admin plugin's
* `dist/plugins/bearer/index.mjs:46` — measured 2026-08-20 against the
* installed better-auth `1.7.1`). The admin plugin's
* `POST /admin/impersonate-user` does the opposite: it mints an impersonation
* session and hands it over as a *cookie* (`setSessionCookie`), parking the
* admin's own session token in a signed `admin_session` cookie for the exit
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,8 +183,10 @@ const UNMAPPED_MANAGED_OBJECTS: Record<string, UnmappedManagedObject> = {
reason:
'Hand-rolled ObjectStack table, not a better-auth model at all. '
+ '`packages/core/src/security/api-key.ts` mints and verifies the key and POST /api/v1/keys '
+ 'writes the row, and better-auth 1.7.0-rc.2 ships NO apiKey plugin: there is no '
+ '"better-auth/plugins/api-key" export subpath and "better-auth/plugins" exports no apiKey. '
+ 'writes the row, and better-auth ships NO apiKey plugin — measured 2026-08-20 against '
+ 'the installed 1.7.1: package.json declares no "./plugins/api-key" export subpath and '
+ 'importing "better-auth/plugins" yields apiKey === undefined (bearer and admin are '
+ 'functions in the same import, so the read is not a silent miss). '
+ 'So no model exists to derive and no column on this table can change hands. '
+ 'Pinned by the premise test at the bottom of this file (#7770).',
noBetterAuthColumns: true,
Expand DownExpand Up@@ -915,9 +917,11 @@ describe('sys_api_key exemption premise (#7770)', () => {
// This is the whole warrant for `noBetterAuthColumns: true` on
// sys_api_key: the columns the issue worried about (`name`, `prefix`,
// `key`, `userId`, `expiresAt`, `permissions`, `metadata`) belong to a
// plugin that does not exist at the pinned version — better-auth 1.7.0-rc.2
// publishes no `./plugins/api-key` subpath and `better-auth/plugins`
// exports no `apiKey`.
// plugin that does not exist at the installed version — measured
// 2026-08-20 against better-auth 1.7.1, which publishes no
// `./plugins/api-key` subpath and whose `better-auth/plugins` exports no
// `apiKey`. The assertion below re-measures this on every run, so the
// stamp is a reading aid and the test is the actual check.
//
// Going red here is CORRECT and is the point: a bump that (re)introduces
// the plugin makes the exemption's premise expire BEFORE anyone can enable
Expand Down
14 changes: 8 additions & 6 deletions packages/plugins/plugin-auth/src/member-role-canonical.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,14 +22,16 @@
* branches fails the extraction and reddens the suite, instead of silently
* leaving a pin that verifies nothing.
*
* The three branches (better-auth 1.7.0-rc.2,
* `dist/plugins/organization/routes/crud-members.mjs`):
* The three branches, re-read 2026-08-20 out of the installed
* better-auth 1.7.1, `dist/plugins/organization/routes/crud-members.mjs`
* (the line numbers are an aid — the extraction below keys on the text, so a
* shift moves them without breaking the pin, while a rewrite reddens it):
*
* 1. `removeMember` — `const roles = toBeRemovedMember.role.split(",");`
* … `if (roles.includes(creatorRole))`
* 1. `removeMember` — `const roles = toBeRemovedMember.role.split(",");` (`:193`)
* … `if (roles.includes(creatorRole))` (`:195`)
* 2. `updateMemberRole` — `const isUpdatingCreator =
* toBeUpdatedMember.role.split(",").includes(creatorRole);`
* 3. `organization/leave` — `if (member.role.split(",").includes(creatorRole))`
* toBeUpdatedMember.role.split(",").includes(creatorRole);` (`:288`)
* 3. `organization/leave` — `if (member.role.split(",").includes(creatorRole))` (`:420`)
*/

import { describe, it, expect, beforeEach } from 'vitest';
Expand Down
8 changes: 5 additions & 3 deletions packages/plugins/plugin-auth/src/member-role-canonical.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,11 +11,13 @@
* 1. the #5942 grade ladder (`orgRoleGrade` / `isOrgAdminGrade`,
* `invitation-role-cap.ts`) — `split(',')` then `trim().toLowerCase()`;
* 2. `mapMembershipRole` (`@objectstack/spec/identity`) — `trim().toLowerCase()`;
* 3. **better-auth itself** — `better-auth@1.7.0-rc.2`,
* 3. **better-auth itself** — measured 2026-08-20 against the installed
* `better-auth@1.7.1`,
* `dist/plugins/organization/routes/crud-members.mjs`, a raw
* `role.split(",")` with NO trim and NO lower-casing, in three branches:
* `removeMember`'s "only an owner may remove an owner", `updateMemberRole`'s
* creator protection, and `organization/leave`'s last-owner count.
* `removeMember`'s "only an owner may remove an owner" (`:193`),
* `updateMemberRole`'s creator protection (`:288`), and
* `organization/leave`'s last-owner count (`:420`).
*
* For a row stored as `Owner` (or `' owner'`), (1) and (2) say owner and (3)
* says plain member. The vendor therefore skips its owner branch entirely and
Expand Down
16 changes: 12 additions & 4 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,12 +239,20 @@ function normaliseComparand(value: unknown): unknown {
*
* It is deliberately NOT a redundant belt over better-auth's own lower-casing.
* better-auth's `internalAdapter` does lower-case `user.email` on
* `createUser` / `createOAuthUser` / `updateUser` / `updateUserByEmail`
* (`better-auth@1.7.0-rc.2/dist/db/internal-adapter.mjs:120,139,594,607`), but
* that is an *internal* of a **prerelease** dependency, invisible to any
* `createOAuthUser` / `createUser` / `updateUser` / `updateUserByEmail` —
* measured 2026-08-20 against the installed `better-auth@1.7.1`, at
* `dist/db/internal-adapter.mjs:127,146,602,615`.
*
* ⚠️ Two things about that sentence changed under it, and only one of them was
* the version number. The previous stamp read `1.7.0-rc.2/…:120,139,594,607`
* and called the dependency a **prerelease**; `1.7.1` is a stable release, so
* that half of the reason is simply dead and is not restamped — it is removed.
* What survives is the half that still holds and still carries the argument:
* the lower-casing is an *internal* of the vendor's `dist`, invisible to any
* published type, and it does not cover the raw {@link createObjectQLAdapter}
* path (hand-built calls that never pass through better-auth at all). The
* invariant the read half depends on has to be owned where it is relied upon.
* invariant the read half depends on has to be owned where it is relied upon —
* which is why this function exists whether or not the vendor keeps doing it.
*
* Idempotent by construction, so a payload better-auth already normalised is
* unchanged — which is why this adds no behaviour to any existing write.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,10 @@ describe('runRegisterSsoProviderFromForm (OIDC) — the emitted body must satisf
// Regression pin for the end-to-end break where the bridge always emitted
// `oidcConfig.mapping.id`, which `oidcMappingSchema` (a `z.strictObject` with
// no `id` member since 1.7.0-rc.2) rejects outright — every OIDC registration
// answered `400 [body.oidcConfig.mapping] Unrecognized key: "id"`.
// answered `400 [body.oidcConfig.mapping] Unrecognized key: "id"`. "since
// 1.7.0-rc.2" is provenance, not a stamp: the member has been absent from
// every release since, the installed `@better-auth/sso@1.7.1` included
// (`dist/index.mjs:1852`, re-read 2026-08-20).
//
// These cases drive the REAL `/sso/register` endpoint. `@better-auth/sso`
// validates the request body BEFORE the endpoint's session gate, so an
Expand Down
15 changes: 12 additions & 3 deletions packages/plugins/plugin-auth/src/register-sso-provider.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,9 +127,10 @@ export async function runRegisterSsoProviderFromForm(
if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ['openid', 'email', 'profile'];

// `oidcConfig.mapping` is a `z.strictObject` in `@better-auth/sso@1.7.0-rc.2`
// (dist/index.mjs, `oidcMappingSchema`): members { email, emailVerified?,
// name, image?, extraFields? }, with `email` and `name` REQUIRED and NO `id`
// `oidcConfig.mapping` is a `z.strictObject`. Measured 2026-08-20 against the
// installed `@better-auth/sso@1.7.1` (`dist/index.mjs:1852`,
// `oidcMappingSchema`): members { email, emailVerified?, name, image?,
// extraFields? }, with `email` and `name` REQUIRED and NO `id`
// member. Emitting `id` is therefore a hard 400 on EVERY registration:
// [body.oidcConfig.mapping] Unrecognized key: "id"
//
Expand All@@ -145,6 +146,14 @@ export async function runRegisterSsoProviderFromForm(
// `extraFields.id` is silently overwritten by `sub` — a no-op that reads as
// configured. The subject claim is simply not configurable any more, so a
// caller that asks for a different one is told so instead of being ignored.
//
// The two sentences above name rc.2 because that is WHEN the member was
// retired; the state they describe is still the installed one. Re-measured
// 2026-08-20 against `@better-auth/sso@1.7.1`, `dist/index.mjs`: the
// `extraFields` spread at `:3921` precedes `id: readStringClaim(rawUserInfo,
// "sub")` at `:3922` (and `:3932` precedes `id: idToken.sub` at `:3933`),
// with the cross-checks at `:3907` (`id_token_subject_missing`) and `:3918`
// (`id_token_userinfo_subject_mismatch`).
const mapId = str(body?.mapId);
if (mapId && mapId !== 'sub') {
return {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,11 @@
//
// ## Where the wrong answer is minted (measured, not assumed)
//
// NOT in our packages. better-auth `1.7.0-rc.2`,
// `dist/plugins/organization/routes/crud-members.mjs`, the `removeMember`
// handler, runs its checks in this order:
// NOT in our packages. Measured 2026-08-20 against the installed better-auth
// `1.7.1`, `dist/plugins/organization/routes/crud-members.mjs`, the
// `removeMember` handler (declared `:139`) runs its checks in this order —
// the branch at `:195`, its two throws at `:196` and `:197-200`, and
// `hasPermission` at `:202-207`:
//
// 1. resolve the caller's own member row → 400 MEMBER_NOT_FOUND
// 2. resolve the target member row → 400 MEMBER_NOT_FOUND
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,13 @@
* ## The defect, and where it is minted
*
* Not here, and not anywhere in our packages: the wrong answer comes out of the
* pinned vendor. better-auth `1.7.0-rc.2`,
* `dist/plugins/organization/routes/crud-members.mjs`, `removeMember` runs:
* pinned vendor. Measured 2026-08-20 against the installed better-auth `1.7.1`
* (what `^1.7.1` resolves to today), in
* `dist/plugins/organization/routes/crud-members.mjs`: `removeMember`
* (declared `:139`) still runs the four checks below in this order — (3a) at
* `:196`, (3b) at `:197-200`, and the real permission check at `:202-207`,
* i.e. AFTER both. Re-check by reading those line numbers; if the order has
* changed upstream, this guard is what should be deleted, not adjusted:
*
* ```js
* const roles = toBeRemovedMember.role.split(",");
Expand Down
Loading
Loading