diff --git a/.changeset/better-auth-team-member-count.md b/.changeset/better-auth-team-member-count.md new file mode 100644 index 0000000000..386b5a201e --- /dev/null +++ b/.changeset/better-auth-team-member-count.md @@ -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. diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index c3f55351aa..ee2d7f2249 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -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: { diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 0e4f991da8..7450b5cc99 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -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, diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 2115959b18..5a760e4a1b 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -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: [ diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index f7fca6a8ce..1bcf217c1a 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -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', }, @@ -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; @@ -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; diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts new file mode 100644 index 0000000000..889b13eb8a --- /dev/null +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -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 }; + +/** Object name → the platform object that provisions its physical table. */ +const PLATFORM_OBJECTS: Record = 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 } + >; +} + +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([]); + }); + } +}); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index 931feb8e48..0f10b55730 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -52,7 +52,13 @@ function toSnakeCase(name: string): string { * `parent_organization_id`. */ function betterAuthFieldsByObject(): Record> { - const tables = getAuthTables({ plugins: [organization()] } as never); + // `teams: { enabled: true }` mirrors the auth-manager default. Without it + // better-auth omits the team models entirely, so the sys_team / + // sys_team_member entries below would be absent and any extension field + // added to those objects would collide silently. (#3624) + const tables = getAuthTables({ + plugins: [organization({ teams: { enabled: true } })], + } as never); const out: Record> = {}; for (const [model, table] of Object.entries(tables ?? {})) { const object = MODEL_TO_OBJECT[model]; diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.ts index f2a4420cc6..32b88b115d 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.ts @@ -33,6 +33,12 @@ * derives better-auth's real field surface from `getAuthTables()` at the PINNED * version and fails the build on any overlap, so the upgrade that would cause * this is the moment someone finds out. + * + * D7 catches only the OVERLAP — a field on both sides. A better-auth upgrade + * that adds a field we have nothing named like sails straight through it and + * fails at runtime instead (`team.memberCount`, #3624). That complementary + * direction — every column better-auth writes must be provisioned — is gated + * by `better-auth-schema-parity.test.ts`. */ /** Object name → the extension fields ObjectStack declares on it. */ diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 6beba4fc31..70f2500af1 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -147,6 +147,9 @@ describe('AUTH_*_SCHEMA plugin table mappings', () => { expect(AUTH_TEAM_SCHEMA.modelName).toBe('sys_team'); expect(AUTH_TEAM_SCHEMA.fields).toEqual({ organizationId: 'organization_id', + // 1.7.0-rc.1's seat counter — unmapped, better-auth wrote a camelCase + // `memberCount` column and every org create 500'd (#3624). + memberCount: 'member_count', createdAt: 'created_at', updatedAt: 'updated_at', }); @@ -157,6 +160,8 @@ describe('AUTH_*_SCHEMA plugin table mappings', () => { expect(AUTH_TEAM_MEMBER_SCHEMA.fields).toEqual({ teamId: 'team_id', userId: 'user_id', + // Derived uniqueness digest, same 1.7.0-rc.1 addition (#3624). + membershipKey: 'membership_key', createdAt: 'created_at', }); }); @@ -166,6 +171,10 @@ describe('AUTH_*_SCHEMA plugin table mappings', () => { expect(AUTH_TWO_FACTOR_SCHEMA.fields).toEqual({ backupCodes: 'backup_codes', userId: 'user_id', + // 1.7's 2FA lockout pair — same unprovisioned-column class as #3624, + // on the wrong-code path. + failedVerificationCount: 'failed_verification_count', + lockedUntil: 'locked_until', }); }); diff --git a/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts new file mode 100644 index 0000000000..0778b6b492 --- /dev/null +++ b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3624 — `POST /auth/organization/create` must not 500 when teams are enabled. + * + * better-auth's organization plugin is on by default (`organization: true` in + * the auth manager) and so is its team sub-feature, which means EVERY org + * create also inserts a default team. better-auth 1.7.0-rc.1 gave that team + * model a `memberCount` column; `sys_team` provisioned no such column and + * `AUTH_TEAM_SCHEMA` carried no mapping for it, so the insert died with + * `table sys_team has no column named memberCount`. + * + * The org row commits BEFORE the team insert runs, so the failure mode was + * uniquely nasty: HTTP 500 on top of a half-created organization — an org row + * with no default team, and a client that was told the whole thing failed. + * + * This is the end-to-end half of the fix. The static half — every column + * better-auth can write must exist on the platform object — is gated by + * `better-auth-schema-parity.test.ts` in plugin-auth, which is what turns the + * NEXT better-auth bump into a red build instead of another production 500. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('#3624: org create provisions its default team', () => { + let stack: VerifyStack; + let token: string; + let priorMultiOrg: string | undefined; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + token = await stack.signIn(); + // `beforeCreateOrganization` denies the route outright unless multi-org is + // on, so a single-tenant boot 403s before better-auth ever reaches the team + // insert. The guard calls `resolveMultiOrgEnabled()` live per request, so + // flipping the flag AFTER boot opens the route while leaving the stack + // itself single-tenant — no OrgScopingPlugin, no enterprise + // `@objectstack/organizations` dependency (which this workspace does not + // ship, and which is why the multi-org RLS dogfood test skips here). + // The regression is in the team INSERT, which is identical either way. + priorMultiOrg = process.env.OS_MULTI_ORG_ENABLED; + process.env.OS_MULTI_ORG_ENABLED = 'true'; + }, 120_000); + + afterAll(async () => { + if (priorMultiOrg === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = priorMultiOrg; + await stack?.stop?.(); + }); + + it('creates the org AND its default team without a 500', async () => { + const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { + name: 'Regression Org 3624', + slug: 'regression-org-3624', + }); + + expect( + res.status, + `organization/create returned ${res.status}: ${await res.clone().text()}`, + ).toBe(200); + + const org = (await res.json()) as { id?: string }; + expect(org.id).toBeTruthy(); + + // The default team is the row that used to blow up. Reading it back proves + // the insert committed, not merely that the endpoint swallowed an error. + const teams = await stack.apiAs(token, 'GET', '/data/sys_team'); + expect(teams.status).toBe(200); + const body = (await teams.json()) as { records?: Array> }; + const team = (body.records ?? []).find((t) => t.organization_id === org.id); + + expect(team, 'no sys_team row for the freshly created org — the default-team insert was lost').toBeDefined(); + // better-auth inserts the team with `memberCount: 0`, then seats the + // creator through `addTeamMemberWithLimit`, which guard-increments the + // counter via the adapter's `incrementOne`. A number here therefore proves + // BOTH writes landed on a real column — an unprovisioned one would surface + // as undefined (or, before the fix, as a 500 on the insert). The exact + // seat count is better-auth's business; only the round-trip is ours. + expect(typeof team?.member_count, `member_count did not round-trip: ${JSON.stringify(team)}`).toBe('number'); + expect(team?.member_count as number).toBeGreaterThanOrEqual(1); + + // Seating the creator writes the second column the same bump added: + // `teamMember.membershipKey`, better-auth's derived (teamId, userId) digest. + const members = await stack.apiAs(token, 'GET', '/data/sys_team_member'); + expect(members.status).toBe(200); + const memberBody = (await members.json()) as { records?: Array> }; + const membership = (memberBody.records ?? []).find((m) => m.team_id === team?.id); + + expect(membership, 'no sys_team_member row — the creator was never seated into the default team').toBeDefined(); + expect( + typeof membership?.membership_key, + `membership_key did not round-trip: ${JSON.stringify(membership)}`, + ).toBe('string'); + }); +});