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
43 changes: 43 additions & 0 deletions .changeset/better-auth-team-member-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/platform-objects": patch
"@objectstack/plugin-auth": patch
---

fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624)

better-auth 1.7.0-rc.1 added fields to three models that the platform objects
never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped
field keeps its camelCase name, the adapter emitted columns no table had:

| model | field | column now provisioned |
|:---|:---|:---|
| `team` | `memberCount` | `sys_team.member_count` |
| `teamMember` | `membershipKey` | `sys_team_member.membership_key` |
| `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` |

The team pair broke org creation outright. The organization plugin's team
sub-feature is on by default, so `POST /api/v1/auth/organization/create`
auto-creates a default team — and that insert died with `table sys_team has no
column named memberCount` *after* the organization row had already committed.
Callers got an HTTP 500 on top of a half-created org: a real org row with no
default team behind it. Every multi-org deployment's create-org flow hit this.

The two-factor pair broke the 2FA lockout path the same way: better-auth
guard-increments `failedVerificationCount` on each wrong code and stamps
`lockedUntil` past the threshold, so a wrong code 500'd instead of being
counted. All four columns are better-auth's own state — provisioned, readable,
and never written from the ObjectStack side.

Existing environments pick the columns up through the driver's additive schema
sync; no data migration is needed. `member_count` backfills to 0 and
better-auth's own `syncTeamMemberCount` reconciles it on the next membership
change, and `membership_key` stays null on pre-upgrade rows, which better-auth
tolerates by falling back to the `(team_id, user_id)` pair.

A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every
column the installed better-auth version can write exists on the platform
object backing it, across the auth manager's whole model surface. The ADR-0092
D7 guard only ever caught *collisions* between our extension fields and
better-auth's, so a bump that adds a brand-new field passed the build and failed
at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId`
regression. The next one fails the build instead.
24 changes: 23 additions & 1 deletion packages/platform-objects/src/identity/sys-team-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,33 @@ export const SysTeamMember = ObjectSchema.create({
label: 'User',
required: true,
}),

// better-auth's own single-column uniqueness boundary for a membership
// (`teamMember.membershipKey`, added alongside `team.memberCount` in
// 1.7.0-rc.1): a SHA-256 digest of [teamId, userId]. The organization
// plugin writes it on every membership insert and relies on the UNIQUE
// constraint to collapse concurrent adds — it catches the insert error
// and re-reads the winner. Declared `input: false, returned: false`
// upstream, so it never crosses the API boundary in either direction.
// Nullable so legacy rows provisioned before the upgrade stay valid;
// better-auth falls back to the (team_id, user_id) pair when the key
// lookup misses. See #3624.
membership_key: Field.text({
label: 'Membership Key',
required: false,
readonly: true,
maxLength: 255,
description: 'Derived membership digest maintained by better-auth; do not write directly.',
}),
},

indexes: [
{ fields: ['team_id', 'user_id'], unique: true },
{ fields: ['user_id'] },
// UNIQUE mirrors better-auth's own declaration — the constraint is what
// makes its concurrent-add recovery work. Nullable columns admit repeated
// NULLs on sqlite / postgres / mysql, so pre-upgrade rows are unaffected.
{ fields: ['membership_key'], unique: true },
],

enable: {
Expand Down
20 changes: 20 additions & 0 deletions packages/platform-objects/src/identity/sys-team.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ export const SysTeam = ObjectSchema.create({
}),

// ── System ───────────────────────────────────────────────────
// better-auth's own durable seat counter (`team.memberCount`, added in
// 1.7.0-rc.1). It is NOT a derived convenience column: the organization
// plugin reserves a seat by guard-incrementing this row BEFORE inserting
// the membership row (`reserveTeamSeat` → `incrementOne`), so it is the
// aggregate capacity boundary `maximumMembersPerTeam` is enforced against.
// better-auth maintains it end to end (create = 0, add = +1, remove = −1,
// plus a `syncTeamMemberCount` reconcile) and strips it from every API
// response — nothing on the ObjectStack side may write it. Provisioned
// here (and mapped in `AUTH_TEAM_SCHEMA`) because better-auth writes the
// column on every team insert; without it, `organization/create` 500s the
// moment teams are enabled. See #3624.
member_count: Field.number({
label: 'Member Count',
required: false,
defaultValue: 0,
readonly: true,
group: 'System',
description: 'Seat counter maintained by better-auth; do not write directly.',
}),

id: Field.text({
label: 'Team ID',
required: true,
Expand Down
25 changes: 25 additions & 0 deletions packages/platform-objects/src/identity/sys-two-factor.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,31 @@ export const SysTwoFactor = ObjectSchema.create({
defaultValue: true,
description: 'Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)',
}),

// better-auth 1.7's 2FA lockout state (`failedVerificationCount` /
// `lockedUntil`). Every wrong code guard-increments the counter and, once
// it crosses `maxFailedAttempts`, stamps `lockedUntil`; a correct code
// resets both. Distinct from `sys_user.failed_login_count` / `locked_until`
// — those are ObjectStack's own password-stage counters (ADR-0069 D2) and
// better-auth is oblivious to them. Declared `input: false, returned:
// false` upstream, so neither crosses the API boundary. Found by the
// parity gate in plugin-auth while closing #3624: same failure shape as
// `team.memberCount` — an unprovisioned column better-auth writes on the
// failure path, so a wrong 2FA code 500'd instead of being counted.
failed_verification_count: Field.number({
label: 'Failed Verification Count',
required: false,
defaultValue: 0,
readonly: true,
description: 'Consecutive failed 2FA verifications; reset on success. Maintained by better-auth.',
}),

locked_until: Field.datetime({
label: 'Locked Until',
required: false,
readonly: true,
description: 'Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth.',
}),
},

indexes: [
Expand Down
37 changes: 33 additions & 4 deletions packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,23 @@ export const AUTH_ORG_SESSION_FIELDS = {
* | camelCase (better-auth) | snake_case (ObjectStack) |
* |:------------------------|:-------------------------|
* | organizationId | organization_id |
* | memberCount | member_count |
* | createdAt | created_at |
* | updatedAt | updated_at |
*
* better-auth 1.7.0-rc.1 added `memberCount` — the durable seat counter the
* plugin guard-increments to reserve capacity before inserting a membership
* row. It is written on EVERY team insert (`memberCount: 0`), so leaving it
* unmapped made the adapter emit a camelCase `memberCount` column that
* `sys_team` never provisioned: `organization/create` auto-creates a default
* team when `teams.enabled`, so org creation 500'd after the org row had
* already committed. See #3624.
*/
export const AUTH_TEAM_SCHEMA = {
modelName: SystemObjectName.TEAM, // 'sys_team'
fields: {
organizationId: 'organization_id',
memberCount: 'member_count',
createdAt: 'created_at',
updatedAt: 'updated_at',
},
Expand All @@ -275,13 +285,21 @@ export const AUTH_TEAM_SCHEMA = {
* |:------------------------|:-------------------------|
* | teamId | team_id |
* | userId | user_id |
* | membershipKey | membership_key |
* | createdAt | created_at |
*
* `membershipKey` landed with `team.memberCount` in 1.7.0-rc.1: a SHA-256
* digest of [teamId, userId] the plugin writes on every membership insert and
* whose UNIQUE constraint collapses concurrent adds. Same failure mode as
* `memberCount` if left unmapped — a camelCase column no table provisions —
* so add-team-member 500s. See #3624.
*/
export const AUTH_TEAM_MEMBER_SCHEMA = {
modelName: SystemObjectName.TEAM_MEMBER, // 'sys_team_member'
fields: {
teamId: 'team_id',
userId: 'user_id',
membershipKey: 'membership_key',
createdAt: 'created_at',
},
} as const;
Expand All @@ -293,16 +311,27 @@ export const AUTH_TEAM_MEMBER_SCHEMA = {
/**
* better-auth Two-Factor plugin `twoFactor` model mapping.
*
* | camelCase (better-auth) | snake_case (ObjectStack) |
* |:------------------------|:-------------------------|
* | backupCodes | backup_codes |
* | userId | user_id |
* | camelCase (better-auth) | snake_case (ObjectStack) |
* |:------------------------|:--------------------------|
* | backupCodes | backup_codes |
* | userId | user_id |
* | failedVerificationCount | failed_verification_count |
* | lockedUntil | locked_until |
*
* 1.7 added the lockout pair `failedVerificationCount` / `lockedUntil`: the
* verify endpoint guard-increments the counter on every wrong code and stamps
* `lockedUntil` once it crosses `maxFailedAttempts`. Unmapped, those writes
* addressed camelCase columns `sys_two_factor` never provisioned, so a wrong
* 2FA code 500'd on the failure path instead of being counted. Found by
* `better-auth-schema-parity.test.ts` while closing #3624.
*/
export const AUTH_TWO_FACTOR_SCHEMA = {
modelName: SystemObjectName.TWO_FACTOR, // 'sys_two_factor'
fields: {
backupCodes: 'backup_codes',
userId: 'user_id',
failedVerificationCount: 'failed_verification_count',
lockedUntil: 'locked_until',
},
} as const;

Expand Down
169 changes: 169 additions & 0 deletions packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Drift gate: every column the INSTALLED better-auth version can write must
* exist on the platform object that backs it.
*
* ## Why this is a separate guard from ADR-0092 D7
*
* `managed-extension-fields.test.ts` derives better-auth's surface from
* `getAuthTables()` too, but it answers the opposite question: *does one of
* OUR extension fields collide with one of THEIRS?* A collision is a field
* present on both sides. This gate catches the other half — a field present
* only on better-auth's side, which D7 waves straight through because there is
* nothing to collide with. A dependency bump that ADDS a model field therefore
* built clean and failed at runtime, twice:
*
* - 1.7's `oauthAccessToken.authorizationCodeId` → 500 at the token endpoint,
* which broke platform SSO for every environment. Closed by the per-plugin
* `oauth-provider-schema-parity.test.ts`, whose check this file generalizes.
* - 1.7.0-rc.1's `team.memberCount` / `teamMember.membershipKey` → 500 on
* `organization/create` (the plugin auto-creates a default team when
* `teams.enabled`, which is the auth-manager default), leaving a committed
* org row with no team behind it. #3624.
*
* Same failure shape both times, in a model nobody had written a per-plugin
* gate for — so this covers the auth manager's WHOLE model surface at once
* rather than one more plugin.
*
* ## What it checks
*
* `getAuthTables()` merges the core models with every plugin's schema and
* applies our `fields` overrides as `fieldName`, exactly as better-auth's
* adapter resolves a column: `field.fieldName ?? key`. Two ways to fail:
*
* 1. **Unmapped model** — better-auth would write a table no platform object
* provisions.
* 2. **Unprovisioned column** — the resolved column is absent from the
* object's fields. Note that an UNMAPPED camelCase field fails here too
* (it resolves to `memberCount`, not `member_count`), so one assertion
* catches both "forgot the snake_case mapping" and "forgot the column".
*
* `@better-auth/oauth-provider` is deliberately absent: it ships as its own
* package with its own pinned version and already has the dedicated gate named
* above. `@better-auth/sso` / `@better-auth/scim` are absent because they
* expose no `schema` option at all — their mapping is applied at the adapter
* layer (see `objectql-adapter.ts`), so `getAuthTables()` cannot report the
* columns they actually write.
*/

import { describe, expect, it } from 'vitest';
import { getAuthTables } from 'better-auth/db';
import { organization, twoFactor, admin } from 'better-auth/plugins';
import { phoneNumber } from 'better-auth/plugins/phone-number';
import { jwt } from 'better-auth/plugins/jwt';
import { deviceAuthorization } from 'better-auth/plugins/device-authorization';
import {
SysAccount,
SysDeviceCode,
SysInvitation,
SysJwks,
SysMember,
SysOrganization,
SysSession,
SysTeam,
SysTeamMember,
SysTwoFactor,
SysUser,
SysVerification,
} from '@objectstack/platform-objects/identity';

import {
AUTH_ACCOUNT_CONFIG,
AUTH_SESSION_CONFIG,
AUTH_USER_CONFIG,
AUTH_VERIFICATION_CONFIG,
buildAdminPluginSchema,
buildDeviceAuthorizationPluginSchema,
buildJwtPluginSchema,
buildOrganizationPluginSchema,
buildPhoneNumberPluginSchema,
buildTwoFactorPluginSchema,
} from './auth-schema-config.js';

type PlatformObject = { name: string; fields?: Record<string, unknown> };

/** Object name → the platform object that provisions its physical table. */
const PLATFORM_OBJECTS: Record<string, PlatformObject> = Object.fromEntries(
([
SysUser, SysSession, SysAccount, SysVerification,
SysOrganization, SysMember, SysInvitation, SysTeam, SysTeamMember,
SysTwoFactor, SysDeviceCode, SysJwks,
] as unknown as PlatformObject[]).map((o) => [o.name, o]),
);

/**
* The model surface the auth manager actually configures — same option shapes
* it passes in `auth-manager.ts`, so the tables this derives are the tables a
* booted environment gets. Plugins that are feature-flagged off in some
* deployments are still included: the column has to exist before the flag can
* be turned on.
*/
function authTables() {
return getAuthTables({
user: AUTH_USER_CONFIG,
session: AUTH_SESSION_CONFIG,
account: AUTH_ACCOUNT_CONFIG,
verification: AUTH_VERIFICATION_CONFIG,
plugins: [
// `teams.enabled` mirrors the auth-manager default. Without it the team
// models drop out of the surface entirely and this gate would have gone
// green through #3624 — the exact hole being closed.
organization({ teams: { enabled: true }, schema: buildOrganizationPluginSchema() }),
twoFactor({ schema: buildTwoFactorPluginSchema() }),
admin({ schema: buildAdminPluginSchema() }),
phoneNumber({ schema: buildPhoneNumberPluginSchema() }),
jwt({ schema: buildJwtPluginSchema() }),
deviceAuthorization({ schema: buildDeviceAuthorizationPluginSchema() }),
],
} as never) as Record<
string,
{ modelName?: string; fields?: Record<string, { fieldName?: string }> }
>;
}

describe('better-auth schema ↔ platform-objects parity (#3624)', () => {
const tables = authTables();

it('derives a non-empty surface (the gate must not pass vacuously)', () => {
expect(Object.keys(tables).length).toBeGreaterThan(0);
// Teams are the regression under test: if the org plugin ever stops
// reporting them, every team assertion below would silently vanish.
expect(Object.keys(tables)).toContain('team');
expect(Object.keys(tables)).toContain('teamMember');
});

it('every better-auth model maps to a platform object', () => {
const unmapped = Object.entries(tables)
.map(([model, table]) => ({ model, table: table.modelName ?? model }))
.filter(({ table }) => !PLATFORM_OBJECTS[table])
.map(({ model, table }) => `${model} → ${table}`);
expect(
unmapped,
'better-auth would write tables no platform object provisions: '
+ `${unmapped.join(', ')} — declare the object in packages/platform-objects/src/identity/ `
+ 'and map it via a modelName in auth-schema-config.ts (or, if the model is intentionally '
+ 'unused, drop the plugin from this gate with a note).',
).toEqual([]);
});

for (const [model, table] of Object.entries(tables)) {
const tableName = table.modelName ?? model;
it(`every ${model} column exists on ${tableName}`, () => {
const object = PLATFORM_OBJECTS[tableName];
if (!object) return; // reported by the mapping assertion above
// better-auth always owns the primary key, whatever the object declares.
const declared = new Set(['id', ...Object.keys(object.fields ?? {})]);
const missing = Object.entries(table.fields ?? {})
.map(([key, field]) => field.fieldName ?? key)
.filter((column) => !declared.has(column));
expect(
missing,
`columns better-auth can write to ${tableName} but the platform object does not declare: `
+ `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/ and, `
+ 'when camelCase ≠ snake_case, a fieldName mapping in auth-schema-config.ts. A camelCase '
+ 'name here means the mapping is missing, not just the column.',
).toEqual([]);
});
}
});
Loading
Loading