Skip to content

Commit bc17d39

Browse files
os-zhuangclaude
andauthored
fix(auth): provision the better-auth 1.7 columns sys_team / sys_team_member / sys_two_factor were missing (#3624) (#3647)
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. An unmapped field keeps its camelCase name, so the adapter emitted columns no table had: 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 — HTTP 500 on top of a half-created org. 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, never written from the ObjectStack side. Existing environments pick them up through the driver's additive schema sync; no data migration is needed. Adds `better-auth-schema-parity.test.ts`: every column the installed better-auth version can write must exist 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 adding a brand-new field passed the build and failed at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId` regression. The two-factor gap above is what the new gate found on its first run. Adds an end-to-end dogfood test that drives the real org-create route and reads the default team back; reverting either half of the fix reproduces the reported 500 verbatim. Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 926f4ad commit bc17d39

10 files changed

Lines changed: 432 additions & 6 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624)
7+
8+
better-auth 1.7.0-rc.1 added fields to three models that the platform objects
9+
never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped
10+
field keeps its camelCase name, the adapter emitted columns no table had:
11+
12+
| model | field | column now provisioned |
13+
|:---|:---|:---|
14+
| `team` | `memberCount` | `sys_team.member_count` |
15+
| `teamMember` | `membershipKey` | `sys_team_member.membership_key` |
16+
| `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` |
17+
18+
The team pair broke org creation outright. The organization plugin's team
19+
sub-feature is on by default, so `POST /api/v1/auth/organization/create`
20+
auto-creates a default team — and that insert died with `table sys_team has no
21+
column named memberCount` *after* the organization row had already committed.
22+
Callers got an HTTP 500 on top of a half-created org: a real org row with no
23+
default team behind it. Every multi-org deployment's create-org flow hit this.
24+
25+
The two-factor pair broke the 2FA lockout path the same way: better-auth
26+
guard-increments `failedVerificationCount` on each wrong code and stamps
27+
`lockedUntil` past the threshold, so a wrong code 500'd instead of being
28+
counted. All four columns are better-auth's own state — provisioned, readable,
29+
and never written from the ObjectStack side.
30+
31+
Existing environments pick the columns up through the driver's additive schema
32+
sync; no data migration is needed. `member_count` backfills to 0 and
33+
better-auth's own `syncTeamMemberCount` reconciles it on the next membership
34+
change, and `membership_key` stays null on pre-upgrade rows, which better-auth
35+
tolerates by falling back to the `(team_id, user_id)` pair.
36+
37+
A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every
38+
column the installed better-auth version can write exists on the platform
39+
object backing it, across the auth manager's whole model surface. The ADR-0092
40+
D7 guard only ever caught *collisions* between our extension fields and
41+
better-auth's, so a bump that adds a brand-new field passed the build and failed
42+
at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId`
43+
regression. The next one fails the build instead.

packages/platform-objects/src/identity/sys-team-member.object.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,33 @@ export const SysTeamMember = ObjectSchema.create({
9999
label: 'User',
100100
required: true,
101101
}),
102+
103+
// better-auth's own single-column uniqueness boundary for a membership
104+
// (`teamMember.membershipKey`, added alongside `team.memberCount` in
105+
// 1.7.0-rc.1): a SHA-256 digest of [teamId, userId]. The organization
106+
// plugin writes it on every membership insert and relies on the UNIQUE
107+
// constraint to collapse concurrent adds — it catches the insert error
108+
// and re-reads the winner. Declared `input: false, returned: false`
109+
// upstream, so it never crosses the API boundary in either direction.
110+
// Nullable so legacy rows provisioned before the upgrade stay valid;
111+
// better-auth falls back to the (team_id, user_id) pair when the key
112+
// lookup misses. See #3624.
113+
membership_key: Field.text({
114+
label: 'Membership Key',
115+
required: false,
116+
readonly: true,
117+
maxLength: 255,
118+
description: 'Derived membership digest maintained by better-auth; do not write directly.',
119+
}),
102120
},
103-
121+
104122
indexes: [
105123
{ fields: ['team_id', 'user_id'], unique: true },
106124
{ fields: ['user_id'] },
125+
// UNIQUE mirrors better-auth's own declaration — the constraint is what
126+
// makes its concurrent-add recovery work. Nullable columns admit repeated
127+
// NULLs on sqlite / postgres / mysql, so pre-upgrade rows are unaffected.
128+
{ fields: ['membership_key'], unique: true },
107129
],
108130

109131
enable: {

packages/platform-objects/src/identity/sys-team.object.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ export const SysTeam = ObjectSchema.create({
140140
}),
141141

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

packages/platform-objects/src/identity/sys-two-factor.object.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,31 @@ export const SysTwoFactor = ObjectSchema.create({
164164
defaultValue: true,
165165
description: 'Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)',
166166
}),
167+
168+
// better-auth 1.7's 2FA lockout state (`failedVerificationCount` /
169+
// `lockedUntil`). Every wrong code guard-increments the counter and, once
170+
// it crosses `maxFailedAttempts`, stamps `lockedUntil`; a correct code
171+
// resets both. Distinct from `sys_user.failed_login_count` / `locked_until`
172+
// — those are ObjectStack's own password-stage counters (ADR-0069 D2) and
173+
// better-auth is oblivious to them. Declared `input: false, returned:
174+
// false` upstream, so neither crosses the API boundary. Found by the
175+
// parity gate in plugin-auth while closing #3624: same failure shape as
176+
// `team.memberCount` — an unprovisioned column better-auth writes on the
177+
// failure path, so a wrong 2FA code 500'd instead of being counted.
178+
failed_verification_count: Field.number({
179+
label: 'Failed Verification Count',
180+
required: false,
181+
defaultValue: 0,
182+
readonly: true,
183+
description: 'Consecutive failed 2FA verifications; reset on success. Maintained by better-auth.',
184+
}),
185+
186+
locked_until: Field.datetime({
187+
label: 'Locked Until',
188+
required: false,
189+
readonly: true,
190+
description: 'Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth.',
191+
}),
167192
},
168193

169194
indexes: [

packages/plugins/plugin-auth/src/auth-schema-config.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,23 @@ export const AUTH_ORG_SESSION_FIELDS = {
252252
* | camelCase (better-auth) | snake_case (ObjectStack) |
253253
* |:------------------------|:-------------------------|
254254
* | organizationId | organization_id |
255+
* | memberCount | member_count |
255256
* | createdAt | created_at |
256257
* | updatedAt | updated_at |
258+
*
259+
* better-auth 1.7.0-rc.1 added `memberCount` — the durable seat counter the
260+
* plugin guard-increments to reserve capacity before inserting a membership
261+
* row. It is written on EVERY team insert (`memberCount: 0`), so leaving it
262+
* unmapped made the adapter emit a camelCase `memberCount` column that
263+
* `sys_team` never provisioned: `organization/create` auto-creates a default
264+
* team when `teams.enabled`, so org creation 500'd after the org row had
265+
* already committed. See #3624.
257266
*/
258267
export const AUTH_TEAM_SCHEMA = {
259268
modelName: SystemObjectName.TEAM, // 'sys_team'
260269
fields: {
261270
organizationId: 'organization_id',
271+
memberCount: 'member_count',
262272
createdAt: 'created_at',
263273
updatedAt: 'updated_at',
264274
},
@@ -275,13 +285,21 @@ export const AUTH_TEAM_SCHEMA = {
275285
* |:------------------------|:-------------------------|
276286
* | teamId | team_id |
277287
* | userId | user_id |
288+
* | membershipKey | membership_key |
278289
* | createdAt | created_at |
290+
*
291+
* `membershipKey` landed with `team.memberCount` in 1.7.0-rc.1: a SHA-256
292+
* digest of [teamId, userId] the plugin writes on every membership insert and
293+
* whose UNIQUE constraint collapses concurrent adds. Same failure mode as
294+
* `memberCount` if left unmapped — a camelCase column no table provisions —
295+
* so add-team-member 500s. See #3624.
279296
*/
280297
export const AUTH_TEAM_MEMBER_SCHEMA = {
281298
modelName: SystemObjectName.TEAM_MEMBER, // 'sys_team_member'
282299
fields: {
283300
teamId: 'team_id',
284301
userId: 'user_id',
302+
membershipKey: 'membership_key',
285303
createdAt: 'created_at',
286304
},
287305
} as const;
@@ -293,16 +311,27 @@ export const AUTH_TEAM_MEMBER_SCHEMA = {
293311
/**
294312
* better-auth Two-Factor plugin `twoFactor` model mapping.
295313
*
296-
* | camelCase (better-auth) | snake_case (ObjectStack) |
297-
* |:------------------------|:-------------------------|
298-
* | backupCodes | backup_codes |
299-
* | userId | user_id |
314+
* | camelCase (better-auth) | snake_case (ObjectStack) |
315+
* |:------------------------|:--------------------------|
316+
* | backupCodes | backup_codes |
317+
* | userId | user_id |
318+
* | failedVerificationCount | failed_verification_count |
319+
* | lockedUntil | locked_until |
320+
*
321+
* 1.7 added the lockout pair `failedVerificationCount` / `lockedUntil`: the
322+
* verify endpoint guard-increments the counter on every wrong code and stamps
323+
* `lockedUntil` once it crosses `maxFailedAttempts`. Unmapped, those writes
324+
* addressed camelCase columns `sys_two_factor` never provisioned, so a wrong
325+
* 2FA code 500'd on the failure path instead of being counted. Found by
326+
* `better-auth-schema-parity.test.ts` while closing #3624.
300327
*/
301328
export const AUTH_TWO_FACTOR_SCHEMA = {
302329
modelName: SystemObjectName.TWO_FACTOR, // 'sys_two_factor'
303330
fields: {
304331
backupCodes: 'backup_codes',
305332
userId: 'user_id',
333+
failedVerificationCount: 'failed_verification_count',
334+
lockedUntil: 'locked_until',
306335
},
307336
} as const;
308337

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Drift gate: every column the INSTALLED better-auth version can write must
5+
* exist on the platform object that backs it.
6+
*
7+
* ## Why this is a separate guard from ADR-0092 D7
8+
*
9+
* `managed-extension-fields.test.ts` derives better-auth's surface from
10+
* `getAuthTables()` too, but it answers the opposite question: *does one of
11+
* OUR extension fields collide with one of THEIRS?* A collision is a field
12+
* present on both sides. This gate catches the other half — a field present
13+
* only on better-auth's side, which D7 waves straight through because there is
14+
* nothing to collide with. A dependency bump that ADDS a model field therefore
15+
* built clean and failed at runtime, twice:
16+
*
17+
* - 1.7's `oauthAccessToken.authorizationCodeId` → 500 at the token endpoint,
18+
* which broke platform SSO for every environment. Closed by the per-plugin
19+
* `oauth-provider-schema-parity.test.ts`, whose check this file generalizes.
20+
* - 1.7.0-rc.1's `team.memberCount` / `teamMember.membershipKey` → 500 on
21+
* `organization/create` (the plugin auto-creates a default team when
22+
* `teams.enabled`, which is the auth-manager default), leaving a committed
23+
* org row with no team behind it. #3624.
24+
*
25+
* Same failure shape both times, in a model nobody had written a per-plugin
26+
* gate for — so this covers the auth manager's WHOLE model surface at once
27+
* rather than one more plugin.
28+
*
29+
* ## What it checks
30+
*
31+
* `getAuthTables()` merges the core models with every plugin's schema and
32+
* applies our `fields` overrides as `fieldName`, exactly as better-auth's
33+
* adapter resolves a column: `field.fieldName ?? key`. Two ways to fail:
34+
*
35+
* 1. **Unmapped model** — better-auth would write a table no platform object
36+
* provisions.
37+
* 2. **Unprovisioned column** — the resolved column is absent from the
38+
* object's fields. Note that an UNMAPPED camelCase field fails here too
39+
* (it resolves to `memberCount`, not `member_count`), so one assertion
40+
* catches both "forgot the snake_case mapping" and "forgot the column".
41+
*
42+
* `@better-auth/oauth-provider` is deliberately absent: it ships as its own
43+
* package with its own pinned version and already has the dedicated gate named
44+
* above. `@better-auth/sso` / `@better-auth/scim` are absent because they
45+
* expose no `schema` option at all — their mapping is applied at the adapter
46+
* layer (see `objectql-adapter.ts`), so `getAuthTables()` cannot report the
47+
* columns they actually write.
48+
*/
49+
50+
import { describe, expect, it } from 'vitest';
51+
import { getAuthTables } from 'better-auth/db';
52+
import { organization, twoFactor, admin } from 'better-auth/plugins';
53+
import { phoneNumber } from 'better-auth/plugins/phone-number';
54+
import { jwt } from 'better-auth/plugins/jwt';
55+
import { deviceAuthorization } from 'better-auth/plugins/device-authorization';
56+
import {
57+
SysAccount,
58+
SysDeviceCode,
59+
SysInvitation,
60+
SysJwks,
61+
SysMember,
62+
SysOrganization,
63+
SysSession,
64+
SysTeam,
65+
SysTeamMember,
66+
SysTwoFactor,
67+
SysUser,
68+
SysVerification,
69+
} from '@objectstack/platform-objects/identity';
70+
71+
import {
72+
AUTH_ACCOUNT_CONFIG,
73+
AUTH_SESSION_CONFIG,
74+
AUTH_USER_CONFIG,
75+
AUTH_VERIFICATION_CONFIG,
76+
buildAdminPluginSchema,
77+
buildDeviceAuthorizationPluginSchema,
78+
buildJwtPluginSchema,
79+
buildOrganizationPluginSchema,
80+
buildPhoneNumberPluginSchema,
81+
buildTwoFactorPluginSchema,
82+
} from './auth-schema-config.js';
83+
84+
type PlatformObject = { name: string; fields?: Record<string, unknown> };
85+
86+
/** Object name → the platform object that provisions its physical table. */
87+
const PLATFORM_OBJECTS: Record<string, PlatformObject> = Object.fromEntries(
88+
([
89+
SysUser, SysSession, SysAccount, SysVerification,
90+
SysOrganization, SysMember, SysInvitation, SysTeam, SysTeamMember,
91+
SysTwoFactor, SysDeviceCode, SysJwks,
92+
] as unknown as PlatformObject[]).map((o) => [o.name, o]),
93+
);
94+
95+
/**
96+
* The model surface the auth manager actually configures — same option shapes
97+
* it passes in `auth-manager.ts`, so the tables this derives are the tables a
98+
* booted environment gets. Plugins that are feature-flagged off in some
99+
* deployments are still included: the column has to exist before the flag can
100+
* be turned on.
101+
*/
102+
function authTables() {
103+
return getAuthTables({
104+
user: AUTH_USER_CONFIG,
105+
session: AUTH_SESSION_CONFIG,
106+
account: AUTH_ACCOUNT_CONFIG,
107+
verification: AUTH_VERIFICATION_CONFIG,
108+
plugins: [
109+
// `teams.enabled` mirrors the auth-manager default. Without it the team
110+
// models drop out of the surface entirely and this gate would have gone
111+
// green through #3624 — the exact hole being closed.
112+
organization({ teams: { enabled: true }, schema: buildOrganizationPluginSchema() }),
113+
twoFactor({ schema: buildTwoFactorPluginSchema() }),
114+
admin({ schema: buildAdminPluginSchema() }),
115+
phoneNumber({ schema: buildPhoneNumberPluginSchema() }),
116+
jwt({ schema: buildJwtPluginSchema() }),
117+
deviceAuthorization({ schema: buildDeviceAuthorizationPluginSchema() }),
118+
],
119+
} as never) as Record<
120+
string,
121+
{ modelName?: string; fields?: Record<string, { fieldName?: string }> }
122+
>;
123+
}
124+
125+
describe('better-auth schema ↔ platform-objects parity (#3624)', () => {
126+
const tables = authTables();
127+
128+
it('derives a non-empty surface (the gate must not pass vacuously)', () => {
129+
expect(Object.keys(tables).length).toBeGreaterThan(0);
130+
// Teams are the regression under test: if the org plugin ever stops
131+
// reporting them, every team assertion below would silently vanish.
132+
expect(Object.keys(tables)).toContain('team');
133+
expect(Object.keys(tables)).toContain('teamMember');
134+
});
135+
136+
it('every better-auth model maps to a platform object', () => {
137+
const unmapped = Object.entries(tables)
138+
.map(([model, table]) => ({ model, table: table.modelName ?? model }))
139+
.filter(({ table }) => !PLATFORM_OBJECTS[table])
140+
.map(({ model, table }) => `${model}${table}`);
141+
expect(
142+
unmapped,
143+
'better-auth would write tables no platform object provisions: '
144+
+ `${unmapped.join(', ')} — declare the object in packages/platform-objects/src/identity/ `
145+
+ 'and map it via a modelName in auth-schema-config.ts (or, if the model is intentionally '
146+
+ 'unused, drop the plugin from this gate with a note).',
147+
).toEqual([]);
148+
});
149+
150+
for (const [model, table] of Object.entries(tables)) {
151+
const tableName = table.modelName ?? model;
152+
it(`every ${model} column exists on ${tableName}`, () => {
153+
const object = PLATFORM_OBJECTS[tableName];
154+
if (!object) return; // reported by the mapping assertion above
155+
// better-auth always owns the primary key, whatever the object declares.
156+
const declared = new Set(['id', ...Object.keys(object.fields ?? {})]);
157+
const missing = Object.entries(table.fields ?? {})
158+
.map(([key, field]) => field.fieldName ?? key)
159+
.filter((column) => !declared.has(column));
160+
expect(
161+
missing,
162+
`columns better-auth can write to ${tableName} but the platform object does not declare: `
163+
+ `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/ and, `
164+
+ 'when camelCase ≠ snake_case, a fieldName mapping in auth-schema-config.ts. A camelCase '
165+
+ 'name here means the mapping is missing, not just the column.',
166+
).toEqual([]);
167+
});
168+
}
169+
});

0 commit comments

Comments
 (0)