Skip to content

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

Merged
os-zhuang merged 1 commit into
mainfrom
claude/org-creation-http-500-teams-gocypm
Jul 27, 2026
Merged

fix(auth): provision the better-auth 1.7 columns sys_team / sys_team_member / sys_two_factor were missing (#3624)#3647
os-zhuang merged 1 commit into
mainfrom
claude/org-creation-http-500-teams-gocypm

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3624.

问题

better-auth 1.7.0-rc.1 给三个 model 加了新字段,而 platform objects 没有 provision 对应的列,auth-schema-config.ts 也没有声明映射。未映射的字段会保留 camelCase 原名,于是 adapter 发出了任何表都没有的列:

better-auth model 新字段 现在 provision 的列
team memberCount sys_team.member_count
teamMember membershipKey sys_team_member.membership_key
twoFactor failedVerificationCount / lockedUntil sys_two_factor.failed_verification_count / locked_until

team 这一对直接打断了建组织流程。 organization plugin 的 teams 子功能默认开启(auth-manager 默认值),所以 POST /api/v1/auth/organization/create 会自动创建一个默认 team,而那条 insert 在 组织行已经提交之后 才失败:

insert into `sys_team` (`created_at`, `id`, `memberCount`, `name`, `organization_id`, `updated_at`)
values (…) - table sys_team has no column named memberCount

调用方拿到 HTTP 500,数据库里却留下一个没有默认 team 的半成品组织 —— 每个多组织部署的建组织流程都会撞上。

two-factor 这一对以同样的方式打断了 2FA 锁定路径(见下)。四个列全部是 better-auth 自己的状态:我们只负责 provision 和读,ObjectStack 侧永远不写。

改动

  • sys_team.member_count —— better-auth 的持久座位计数器。它不是派生的便利列:plugin 在插入 membership 行 之前 先 guard-increment 这一行来预留座位(reserveTeamSeatincrementOne),maximumMembersPerTeam 就是拿它做边界的。
  • sys_team_member.membership_key —— [teamId, userId] 的 SHA-256 摘要,better-auth 用它的 UNIQUE 约束来收敛并发添加。可空,所以升级前的老行仍然合法;key 查不到时 better-auth 会回退到 (team_id, user_id) 组合。索引照抄上游的 UNIQUE 声明。
  • sys_two_factor.failed_verification_count / locked_until —— 1.7 的 2FA 锁定状态。每次输错验证码都会 guard-increment 计数器,越过 maxFailedAttempts 就打上 lockedUntil。未映射时,输错验证码会在失败路径上 500,而不是被计数。这是新 gate 第一次运行就发现的,与 org creation over HTTP 500s when teams are enabled: better-auth 1.7.0-rc.1 default team carries memberCount, sys_team lacks the column/mapping #3624 属于同一类缺陷,不修就没法让 gate 变绿。
  • 三处 AUTH_*_SCHEMA 补上 snake_case 映射。

存量环境通过 driver 的 additive schema sync 拿到这些列,不需要数据迁移:member_count 回填为 0,better-auth 自己的 syncTeamMemberCount 会在下一次成员变更时对账;membership_key 在老行上保持 null。

防回归

Issue 里提到 ADR-0092 D7 的 guard 只抓 冲突、抓不到 缺列。新增 better-auth-schema-parity.test.ts:从 getAuthTables() 按 better-auth adapter 解析列名的同一规则(field.fieldName ?? key)推导出 auth manager 整个 model 面,断言每一列都存在于对应的 platform object 上。两种失败方式:model 没有对应对象,或列没有 provision —— 未映射的 camelCase 字段也会落在后者(解析成 memberCount 而非 member_count),所以一条断言同时覆盖"忘了映射"和"忘了加列"。

这一类缺陷已经出现两次了(1.7 的 oauthAccessToken.authorizationCodeId 是上一次,当时只补了一个 per-plugin gate),所以这次覆盖整个 model 面,而不是再加一个单插件的检查。顺带把 D7 collision guard 的 organization() 补上 teams: { enabled: true } —— 之前 team 两张表根本不在它推导的面里。

另加一个端到端 dogfood 测试,走真实的 org-create 路由再把默认 team 读回来。把修复的任一半 revert 掉,都能一字不差地复现 issue 里的 500(已实测)。

测试

套件 结果
@objectstack/plugin-auth 519 passed (23 files)
@objectstack/platform-objects 215 passed (5 files)
@objectstack/objectql 1091 passed (74 files)
@objectstack/rest 394 passed (26 files)
新增 dogfood e2e passed

@objectstack/dogfood 全量套件在提交时仍在跑,CI 结果为准。


Generated by Claude Code

…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. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 12:39pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, packages/qa.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via packages/qa)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 12:54
@os-zhuang
os-zhuang merged commit bc17d39 into main Jul 27, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/org-creation-http-500-teams-gocypm branch July 27, 2026 12:54
os-zhuang added a commit that referenced this pull request Jul 27, 2026
… translation bundles (#3624 follow-up) (#3659)

* test(auth): cover the better-auth 2FA lockout path; regenerate object translation bundles (#3624 follow-up)

Follow-up to #3647, which provisioned four better-auth 1.7 columns that no
platform object had. Two loose ends from that PR.

## The 2FA lockout path had no test at all

`sys_two_factor.failed_verification_count` / `locked_until` were added blind
alongside the team fix — found by the new parity gate, not by anything
exercising the behavior. That is why they went missing in the first place: the
whole path was untested, so a column better-auth writes on every wrong code
could vanish without a single failure.

The new dogfood test pins the behavior, not just the schema: a wrong code is
COUNTED (the counter advances in the database), and a correct code RESETS the
count — which is what makes the budget track *consecutive* failures.

It has to go through the mid-sign-in two-factor cookie, not a bearer token:
better-auth only runs `assertTwoFactorNotLocked` / `recordTwoFactorFailure`
when `isSignIn` is true, and deliberately skips both when the same endpoint is
called with a live session. A bearer-token test would have passed while the
lockout path stayed entirely unexercised — the exact blind spot that let the
columns through.

Reverting the `AUTH_TWO_FACTOR_SCHEMA` mapping makes it fail, and shows the
breakage was wider than the wrong-code path: `two-factor/enable` itself 500s
with `table sys_two_factor has no column named failedVerificationCount`, so
enrollment was broken too.

TOTP is generated in-test from the otpauth:// secret via RFC 6238 over
node:crypto rather than importing `@better-auth/utils/otp` — that is a
transitive dependency, and taking a direct one on it to produce six digits
would tie the test to an internal package's resolution.

## Translation bundles

`os i18n extract` regenerated for the four new fields, which were absent from
every locale bundle. Also picks up `sys_organization.parent_organization_id` /
`sort_order` from the earlier ADR-0105 D6 work, which had the same gap.

The `*.metadata-forms.generated.ts` bundles are deliberately NOT included: the
same run rewrites them with unrelated spec drift (agent `visibility` and
`capabilities.trash`/`mru` removed, `summaryOperations.*` added). Those are
DELETIONS of curated translations for a spec change this branch has nothing to
do with, so they are left for whoever owns that change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H

* chore(changeset): declare the object-translation bundle update (#3624 follow-up)

The regenerated bundles ship inside @objectstack/platform-objects, so the
change is consumer-visible and takes a real patch changeset rather than an
empty one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…*` wildcard becomes 55 exact rows (#3656) (#3669)

The widest hole the #3642 capstone measured: 60 of ~196 matched SDK calls
rested only on a `**` prefix claim, 54 of them on `* /auth/**` — the largest
and most security-relevant namespace in the client. `auth.me` builds
/api/v1/auth/get-session, and a prefix claim cannot tell you better-auth still
calls it that; better-auth is a third-party dependency on its own release
cadence (cf. the 1.7 column drift chased in #3624 / #3647).

plugin-auth mounts it with one catch-all, so there are no per-route
registration calls to capture. The seam is `auth.api`: every better-auth
endpoint carries .path and .options.method, so a live instance IS the route
table. auth-route-ledger.ts reads it, in two halves checked differently on
purpose — 55 reviewed rows (every route the SDK calls, each naming its client
method) verified strictly as a rename detector, plus a 129-path mounted-surface
inventory checked for exact equality so a version bump that adds publicly
mounted auth endpoints becomes a reviewable diff rather than silent surface.

Enumeration is config-dependent, so the inventory is pinned at the
configuration enabling every plugin the SDK targets, with the participating OS_*
env vars cleared. Mutation-checked: renaming a ledgered route fails the suite.

The capstone guard now includes this ledger and prefers exact rows over wildcard
families when matching — without that ordering fix every /auth/* URL would still
be absorbed by `* /auth/**`. Wildcard-only matches fall 60 → 3.

No runtime change.

Closes #3656.
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…-check` (#3683)

* feat(cli): gate generated translation bundles with `os i18n extract --check`

The generated translation bundles had no freshness gate. Nothing failed when
they fell behind the schema, so they only ever got fixed when someone happened
to re-run the extractor by hand — and by the time anyone did (#3670), three
distinct drifts had accumulated at once:

  - translations left behind for keys the schema had REMOVED (`enable.trash` /
    `enable.mru`, #2377; agent `visibility`, #1901) — keys the schema now
    rejects outright;
  - keys the schema had GAINED with no entry in any locale
    (`summaryOperations.*`, the ADR-0105 D8 invitation-placement fields, and
    the better-auth 1.7 columns from #3647);
  - `sys_migration` committed with EMPTY STRINGS for its labels in ja-JP and
    es-ES, which renders blank rather than falling back to anything readable.

This is the same shape as #3624: an artifact and its source with no gate
between them, so the gap is only found by accident. #3647 closed that one for
better-auth's columns by deriving the expected surface and failing the build;
this does the same for translations.

`--check` renders exactly what a real extract would write — both branches
iterate one shared rendered set, so the check cannot drift from the writer —
then compares against `--out` instead of writing, listing every stale or
missing file and printing the regenerate command. It runs in merge mode like
any other extract, so it never asks for re-translation: an up-to-date bundle
re-extracts byte-identically.

The flag lives on the CLI rather than in a repo script, so any consumer that
ships generated bundles can gate them the same way. Here it is `pnpm
check:i18n`, wired into lint.yml beside the other post-build consumer gates
(it reads the built spec dist through the extract config).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H

* docs(i18n): document the extract --check freshness gate

The docs and the i18n skill both presented `os i18n check` as *the* CI gate.
It is the coverage gate — it answers 'are the strings translated?'. For anyone
committing generated bundles that leaves the other half unguarded: renaming a
label, adding an object, or removing a spec key keeps coverage at 100% while
the bundles quietly go stale.

Documents the two as a pair, in both surfaces, since the skill is what ships to
third parties via `npx skills add`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…r.provider_key (#3653) (#3688)

The better-auth parity gate (#3647) shipped with a known hole: `@better-auth/sso`
and `@better-auth/scim` accept no `schema` option, so `getAuthTables()` cannot
see them and they were excluded with a comment. A comment is not a gate — filed
as #3653 rather than left buried, and closed here.

Both plugins DO expose their own `.schema`, and the adapter bridges them by
mechanically camelCase→snake_casing every field of a bridged model
(`objectql-adapter.ts`). That mechanical rule — not any hand-written mapping —
is what decides the column they write, so the gate now reads the former and
applies the latter.

What it found on first run:

  @better-auth/sso    clean. All 7 ssoProvider fields already provisioned.
  @better-auth/scim   sys_scim_provider was missing `provider_key`.

`providerKey` is declared `required: true, unique: true` upstream and written on
every provider insert (a derived `<organization>:<provider_id>` key, same shape
as `sys_team_member.membership_key`). Without the column, creating a SCIM
provider fails the moment SCIM is switched on — #3624 again, behind a flag.

The four SCIM **group** models (`scimGroup`, `scimGroupMember`, `scimGroupRole`,
`scimGroupRoleGrant`) have no platform object at all. That is a feature-sized
gap, not a missing column, so it is NOT silently skipped: `KNOWN_UNMAPPED_MODELS`
pins them as an exact set and the set itself is asserted. A new unmapped model
fails the build, and a model that gains an object also fails — telling whoever
provisioned it to move it into the column check. #3653 now tracks that work.

Also raised there, deliberately NOT changed here: `sys_scim_provider` carries a
UNIQUE index on `provider_id` alone, while upstream's boundary is
`<organization>:<provider_id>` — so the same provider id in two organizations is
legal upstream and rejected here. Relaxing a live uniqueness constraint is its
own decision.


Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

org creation over HTTP 500s when teams are enabled: better-auth 1.7.0-rc.1 default team carries memberCount, sys_team lacks the column/mapping

2 participants