7373 * - `findCollisions()` is a pure function, exercised against a SYNTHETIC
7474 * registry that declares a column only a widened plugin contributes — so the
7575 * red direction is pinned in-repo, not merely asserted in a PR description.
76+ *
77+ * ## The third axis: mapping what the widening made derivable (#7994)
78+ *
79+ * Widening the plugin set had a consequence #7820 did not spend: the models
80+ * `twoFactor` / `jwks` / `deviceCode` started being derived, while their three
81+ * objects stayed in `UNMAPPED_MANAGED_OBJECTS`. The exemptions outlived their
82+ * granted reason — registered because the plugins "are not loaded by this
83+ * call", which had become false — and were restated once in place on the
84+ * weaker reason that the models were merely unmapped. An exemption whose
85+ * justification has to be rewritten to survive is a smell, not a record.
86+ *
87+ * The 2026-08-12 ruling mapped all three and retired the exemptions, taking
88+ * coverage from nine objects to twelve, and explicitly amended the #7820
89+ * ruling's 「保持不动」 line to have scoped that card only. `sys_two_factor`
90+ * and `sys_jwks` hold credential material (`secret`, `privateKey`), which is
91+ * precisely the kind of column D7 exists to stop an extension field from
92+ * silently taking ownership of.
93+ *
94+ * Its acceptance criterion is the one that matters for any coverage change,
95+ * and is worth copying: not "the map has twelve entries" — a count derived
96+ * from the structure it checks, which cannot fail — but a SYNTHETIC COLLISION
97+ * PER TABLE that must turn this suite red, and does not when the mapping is
98+ * removed. That is the last describe block but one.
7699 */
77100
78101import { describe , it , expect } from 'vitest' ;
@@ -113,6 +136,17 @@ const MODEL_TO_OBJECT: Record<string, string> = {
113136 invitation : 'sys_invitation' ,
114137 team : 'sys_team' ,
115138 teamMember : 'sys_team_member' ,
139+ // The three opt-in-plugin models #7770 exempted and the 2026-08-12 ruling
140+ // mapped (#7994). Each is derivable only because #7820 widened the plugin
141+ // set above to everything `auth-manager.ts` can assemble: `twoFactor()`,
142+ // `jwt()` and `deviceAuthorization()` are the plugins that contribute them,
143+ // so deleting any of those from AUTH_MANAGER_PLUGINS silently withdraws the
144+ // object from the collision loop again. Both halves are pinned — the
145+ // coverage assertion below, and a per-table synthetic collision at the
146+ // bottom of this file.
147+ twoFactor : 'sys_two_factor' ,
148+ jwks : 'sys_jwks' ,
149+ deviceCode : 'sys_device_code' ,
116150} ;
117151
118152interface UnmappedManagedObject {
@@ -157,41 +191,22 @@ const UNMAPPED_MANAGED_OBJECTS: Record<string, UnmappedManagedObject> = {
157191 noBetterAuthColumns : true ,
158192 } ,
159193
160- // ── Opt-in core plugins: derived, but deliberately not mapped ─────────────
161- // #7770 registered these three because this file's getAuthTables() call did
162- // not load their plugins. #7820 widened that call to the auth manager's whole
163- // set, so the models ARE derived now — the reasons below are restated to say
164- // so rather than keep claiming an absence that stopped being true. What did
165- // NOT change is the disposition: mapping them is a coverage EXPANSION #7770
166- // deferred and the 2026-08-12 ruling kept unmoved, so `MODEL_TO_OBJECT` still
167- // does not name them and `betterAuthFieldsByObject()` still drops them.
194+ // ── sys_two_factor / sys_jwks / sys_device_code are NO LONGER HERE ────────
195+ // #7770 exempted all three because this file's getAuthTables() call did not
196+ // load their plugins. #7820 widened the call, which made that reason false,
197+ // and the exemptions survived one restatement on a weaker reason: the models
198+ // were derived, merely unmapped. The 2026-08-12 ruling (#7994) ended that —
199+ // 「map twoFactor / jwks / deviceCode into MODEL_TO_OBJECT, add the three
200+ // COVERED_OBJECTS entries, and retire their three #7770 exemptions」 —
201+ // explicitly amending the #7820 ruling's 「保持不动」 line to have scoped
202+ // that card only. Coverage went 9 → 12 objects.
168203 //
169- // The safety note is the load-bearing half and is untouched: this costs
170- // nothing while no extension field is declared on them, and the moment one
171- // is, the "nothing is silently skipped" assertion below turns red and the
172- // mapping has to be done properly. Column-level parity for all three is
173- // covered meanwhile by `better-auth-schema-parity.test.ts`.
174- sys_two_factor : {
175- reason :
176- "Owned by better-auth's opt-in `twoFactor` plugin (model `twoFactor`). The plugin is in this "
177- + "file's getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does not map the "
178- + 'object — mapping it is a coverage expansion, not part of that fix. No extension field is '
179- + 'declared on it.' ,
180- } ,
181- sys_device_code : {
182- reason :
183- "Owned by better-auth's opt-in `deviceAuthorization` plugin (model `deviceCode`). The plugin "
184- + "is in this file's getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does "
185- + 'not map the object — mapping it is a coverage expansion, not part of that fix. No extension '
186- + 'field is declared on it.' ,
187- } ,
188- sys_jwks : {
189- reason :
190- "Owned by better-auth's opt-in `jwt` plugin (model `jwks`). The plugin is in this file's "
191- + 'getAuthTables() call since #7820, but MODEL_TO_OBJECT deliberately does not map the object '
192- + '— mapping it is a coverage expansion, not part of that fix. No extension field is declared '
193- + 'on it.' ,
194- } ,
204+ // ⛔ Do not re-add them here to silence a failure. An exemption is what makes
205+ // the collision loop skip a table, and these three hold credential-adjacent
206+ // columns (`twoFactor.secret`, `jwks.privateKey`): re-exempting reopens
207+ // exactly the invisible-hole shape #7770 was filed for. If the real fix is
208+ // to stop deriving one of them, remove its plugin from AUTH_MANAGER_PLUGINS
209+ // and say why — the coverage assertion will then demand this list change too.
195210
196211 // ── Plugins getAuthTables() cannot ADDRESS as an ObjectStack object (#3653) ─
197212 // Both plugins are now in the call (#7820), so their models do appear in the
@@ -231,7 +246,7 @@ const UNMAPPED_MANAGED_OBJECTS: Record<string, UnmappedManagedObject> = {
231246const MAPPED_OBJECTS : readonly string [ ] = Object . values ( MODEL_TO_OBJECT ) ;
232247
233248/**
234- * The nine objects this guard has real collision coverage for, written out as
249+ * The twelve objects this guard has real collision coverage for, written out as
235250 * a LITERAL rather than derived from `MODEL_TO_OBJECT`.
236251 *
237252 * Deriving it would have made the pin below unable to see the regression it
@@ -240,6 +255,12 @@ const MAPPED_OBJECTS: readonly string[] = Object.values(MODEL_TO_OBJECT);
240255 * collision loop. Measured — an ablation that removed `teamMember` left a
241256 * derived version of this pin green and was caught only by the accounting
242257 * assertion. Two independent lists, so one of them has to be wrong out loud.
258+ *
259+ * ⚠️ This list is a COUNT, and a count certifies nothing on its own: it says
260+ * which objects the loop reaches, not that reaching them judges anything. The
261+ * assertion that the three objects added in #7994 are really judged is the
262+ * per-table synthetic collision at the bottom of this file, which fails the
263+ * moment a mapping is deleted.
243264 */
244265const COVERED_OBJECTS : readonly string [ ] = [
245266 'sys_user' ,
@@ -251,6 +272,10 @@ const COVERED_OBJECTS: readonly string[] = [
251272 'sys_invitation' ,
252273 'sys_team' ,
253274 'sys_team_member' ,
275+ // #7994 — the three opt-in-plugin tables, mapped by the 2026-08-12 ruling.
276+ 'sys_two_factor' ,
277+ 'sys_jwks' ,
278+ 'sys_device_code' ,
254279] ;
255280
256281const HERE = dirname ( fileURLToPath ( import . meta. url ) ) ;
@@ -472,14 +497,14 @@ describe('managed extension fields (ADR-0105 D7)', () => {
472497 expect ( byObject . sys_user ?. size ?? 0 ) . toBeGreaterThan ( 0 ) ;
473498 } ) ;
474499
475- it ( 'keeps the nine mapped models covered, exactly (coverage cannot shrink)' , ( ) => {
500+ it ( 'keeps the twelve mapped models covered, exactly (coverage cannot shrink)' , ( ) => {
476501 // Two ways an object can silently withdraw from the collision loop, and
477502 // this pins both against the same literal: the LIBRARY stops emitting the
478503 // model, or the MAP stops naming it. Either is the #7770 shape arriving
479504 // as a regression rather than as a pre-existing absence.
480505 expect (
481506 [ ...MAPPED_OBJECTS ] . sort ( ) ,
482- 'MODEL_TO_OBJECT no longer maps exactly the nine covered objects — a deleted mapping '
507+ 'MODEL_TO_OBJECT no longer maps exactly the twelve covered objects — a deleted mapping '
483508 + 'withdraws an object from the collision loop as surely as a missing one ever did.' ,
484509 ) . toEqual ( [ ...COVERED_OBJECTS ] . sort ( ) ) ;
485510 expect (
@@ -740,6 +765,88 @@ describe('the collision rule goes red on a FUTURE overlap (#7820)', () => {
740765 } ) ;
741766} ) ;
742767
768+ describe ( 'the three newly mapped tables are really judged (#7994)' , ( ) => {
769+ // The 2026-08-12 ruling's acceptance criterion, stated as tests rather than
770+ // as a count. "Coverage goes 9 → 12" is satisfied by the COVERED_OBJECTS pin
771+ // above — but that pin only proves the collision loop REACHES these tables.
772+ // What follows proves it JUDGES them: each case names a column better-auth
773+ // really owns on that table at the pinned version and requires the rule to
774+ // report it.
775+ //
776+ // These fail the moment a mapping is deleted, which is what makes them worth
777+ // having: with `twoFactor` out of MODEL_TO_OBJECT, `byObject.sys_two_factor`
778+ // is undefined, `findCollisions`'s `if (!owned) continue` swallows the
779+ // synthetic field, the call returns `[]` and every expectation here goes red.
780+ // (Verified by ablation before this landed, in both directions.)
781+ //
782+ // Deliberately driven through a SYNTHETIC registry rather than by adding
783+ // entries to `MANAGED_EXTENSION_FIELDS`: that map is also the ADR-0092 D2
784+ // write whitelist, so a column added there widens what a generic write
785+ // surface may touch. On `sys_two_factor.secret` or `sys_jwks.private_key`
786+ // that is a security change, and D7 coverage must never be bought with one.
787+ const byObject = betterAuthFieldsByObject ( ) ;
788+
789+ const OWNED : Array < [ object_ : string , snake : string , camel : string , plugin : string ] > = [
790+ [ 'sys_two_factor' , 'backup_codes' , 'backupCodes' , 'twoFactor' ] ,
791+ [ 'sys_jwks' , 'private_key' , 'privateKey' , 'jwt' ] ,
792+ [ 'sys_device_code' , 'user_code' , 'userCode' , 'deviceAuthorization' ] ,
793+ ] ;
794+
795+ for ( const [ object_ , snake , camel , plugin ] of OWNED ) {
796+ it ( `reports a synthetic collision on ${ object_ } (both spellings)` , ( ) => {
797+ const why =
798+ `${ object_ } was mapped into MODEL_TO_OBJECT by the 2026-08-12 ruling so that a platform `
799+ + `extension field colliding with better-auth's own column is caught here. A green result `
800+ + `means the table is back OUTSIDE the collision loop — most likely its mapping was `
801+ + `deleted, or the ${ plugin } plugin left AUTH_MANAGER_PLUGINS so no model is derived for `
802+ + `it. Either way this guard is answering about a table it is not looking at, which is the `
803+ + `#7770 defect. Do NOT "fix" this by re-adding an UNMAPPED_MANAGED_OBJECTS exemption.` ;
804+ expect ( findCollisions ( { [ object_ ] : new Set ( [ snake ] ) } , byObject ) , why )
805+ . toEqual ( [ `${ object_ } .${ snake } ` ] ) ;
806+ // better-auth authors camelCase; `toSnakeCase` records both spellings, so
807+ // a registry that happened to use the library's own spelling must not
808+ // slip past a snake_case-only comparison.
809+ expect ( findCollisions ( { [ object_ ] : new Set ( [ camel ] ) } , byObject ) , why )
810+ . toEqual ( [ `${ object_ } .${ camel } ` ] ) ;
811+ } ) ;
812+ }
813+
814+ it ( 'stays silent on plausible extension names these tables do NOT own' , ( ) => {
815+ // Non-vacuity, per table: a rule that reported every field would satisfy
816+ // the three cases above while making the real registry assertion useless.
817+ // Each name below is one a future ObjectStack extension might genuinely
818+ // take, and none is in better-auth's surface at the pinned version.
819+ expect ( findCollisions ( { sys_two_factor : new Set ( [ 'mfa_policy_id' ] ) } , byObject ) ) . toEqual ( [ ] ) ;
820+ expect ( findCollisions ( { sys_jwks : new Set ( [ 'rotation_note' ] ) } , byObject ) ) . toEqual ( [ ] ) ;
821+ expect ( findCollisions ( { sys_device_code : new Set ( [ 'approved_by_id' ] ) } , byObject ) ) . toEqual ( [ ] ) ;
822+ } ) ;
823+
824+ it ( 'derives a credential-bearing column set for each of the three' , ( ) => {
825+ // The ② lens of the card: these tables are why the expansion was worth
826+ // doing. If better-auth ever moves `secret` / `privateKey` / `deviceCode`
827+ // off these models, the collision cases above would start passing for the
828+ // wrong reason (nothing owned, nothing to collide with) — this says so
829+ // first, and names the column that moved.
830+ expect ( byObject . sys_two_factor ?. has ( 'secret' ) , 'twoFactor.secret' ) . toBe ( true ) ;
831+ expect ( byObject . sys_jwks ?. has ( 'private_key' ) , 'jwks.privateKey' ) . toBe ( true ) ;
832+ expect ( byObject . sys_device_code ?. has ( 'device_code' ) , 'deviceCode.deviceCode' ) . toBe ( true ) ;
833+ } ) ;
834+
835+ it ( 'carries no exemption for the three tables any more' , ( ) => {
836+ // The ruling's second half, in-repo. The stale-entry assertion above
837+ // already fails on a mapped-AND-exempt object; this states the intent
838+ // directly, so the next reader sees a removal that was decided rather
839+ // than an entry someone lost track of.
840+ for ( const object_ of [ 'sys_two_factor' , 'sys_jwks' , 'sys_device_code' ] ) {
841+ expect (
842+ UNMAPPED_MANAGED_OBJECTS [ object_ ] ,
843+ `${ object_ } is exempt again. An exemption is what makes the collision loop skip a table, `
844+ + `so re-adding one here silently un-does the 2026-08-12 ruling's coverage expansion.` ,
845+ ) . toBeUndefined ( ) ;
846+ }
847+ } ) ;
848+ } ) ;
849+
743850describe ( 'sys_api_key exemption premise (#7770)' , ( ) => {
744851 it ( 'better-auth still ships no apiKey plugin, so sys_api_key has no model to collide with' , async ( ) => {
745852 // This is the whole warrant for `noBetterAuthColumns: true` on
0 commit comments