fix(auth): provision the better-auth 1.7 columns sys_team / sys_team_member / sys_two_factor were missing (#3624) - #3647
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 27, 2026 12:54
This was referenced Jul 27, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3624.
问题
better-auth 1.7.0-rc.1 给三个 model 加了新字段,而 platform objects 没有 provision 对应的列,
auth-schema-config.ts也没有声明映射。未映射的字段会保留 camelCase 原名,于是 adapter 发出了任何表都没有的列:teammemberCountsys_team.member_countteamMembermembershipKeysys_team_member.membership_keytwoFactorfailedVerificationCount/lockedUntilsys_two_factor.failed_verification_count/locked_untilteam 这一对直接打断了建组织流程。 organization plugin 的 teams 子功能默认开启(auth-manager 默认值),所以
POST /api/v1/auth/organization/create会自动创建一个默认 team,而那条 insert 在 组织行已经提交之后 才失败:调用方拿到 HTTP 500,数据库里却留下一个没有默认 team 的半成品组织 —— 每个多组织部署的建组织流程都会撞上。
two-factor 这一对以同样的方式打断了 2FA 锁定路径(见下)。四个列全部是 better-auth 自己的状态:我们只负责 provision 和读,ObjectStack 侧永远不写。
改动
sys_team.member_count—— better-auth 的持久座位计数器。它不是派生的便利列:plugin 在插入 membership 行 之前 先 guard-increment 这一行来预留座位(reserveTeamSeat→incrementOne),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@objectstack/platform-objects@objectstack/objectql@objectstack/rest@objectstack/dogfood全量套件在提交时仍在跑,CI 结果为准。Generated by Claude Code