From 69b6ddd2201267f6661222f417c16d12248eff00 Mon Sep 17 00:00:00 2001 From: os-sam Date: Sun, 23 Aug 2026 15:46:48 +0000 Subject: [PATCH 1/2] fix(plugin-auth): give the SCIM parity gate a constructor call stable accepts The gate is the declared safety net for the @better-auth/scim migration (#3653): the day the pin moves off 1.7.0-rc.1 it is supposed to name every model whose platform object is missing. It did not open at that moment. `scim({})` is legal on the pinned rc.1 (options optional, nothing read at construction), but the stable factory's first statement is `validateConnections(options)`, which reaches `options.connections.length` before any existence check. The schema is read in the describe body and in a `for` header, so on stable the file dies during COLLECTION with TypeError: Cannot read properties of undefined (reading 'length') and prints that instead of the model diff it promises. Construct with `connections: []` plus a never-invoked bearer-token verifier -- the cheapest shape stable accepts (an empty connection list is only legal when a verifier or the managed catalog resolves connections), and inert on the pinned rc.1, whose factory spreads its options and reads only the keys it knows. `managedConnections` is deliberately not configured: the auth manager does not pass it, and it is what adds the conditional managed trio. A constructor mismatch can only ever be a collection failure here -- the `for` header has to read the schema -- but it does not have to be illegible, so the call is wrapped and re-thrown with the vendor error as `cause` and an instruction naming the constructor as the broken half. Gate-only change: no product code, no published surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../src/better-auth-schema-parity.test.ts | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) 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 index 786ab78744..f6d8386b7c 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -456,6 +456,85 @@ function adapterColumn(field: string): string { return field.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); } +/** + * Construct `@better-auth/scim` with options BOTH the pinned prerelease and the + * stable line accept, so this gate reaches its diff-printing stage instead of + * dying in collection (#11380). + * + * The gate was written down as the safety net for the `@better-auth/scim` + * migration (#3653): the day the pin moves off `1.7.0-rc.1`, it is supposed to + * name every model whose platform object is missing. It did not open at that + * moment. `scim({})` is legal on the pinned rc.1 — `options` is optional there + * and nothing is read at construction — but the stable factory's FIRST + * statement is `validateConnections(options)`, which reaches + * `options.connections.length` before any existence check. So on stable the + * call throws + * + * TypeError: Cannot read properties of undefined (reading 'length') + * + * and, because the schema is read in the describe body and in the `for` header + * below, it throws during COLLECTION — the whole file dies and prints that + * TypeError instead of the model list it promises. A migrator gets an + * uninformative crash from the guard that exists to inform them: declared, not + * enforced. + * + * `connections: []` alone is not enough on stable — an empty connection list is + * only legal when a bearer-token verifier or the managed catalog resolves + * connections, otherwise `validateConnections` throws + * `BetterAuthError: The scim plugin requires a provisioning connection, bearer + * token verifier, or managed connection catalog.` The verifier below is the + * cheapest way to satisfy that, and is never invoked: this gate only reads the + * schema the plugin declares. Both keys are inert on the pinned rc.1, whose + * factory spreads its options and reads only the keys it knows. + * + * ⚠️ Deliberately NOT configured: `managedConnections`. The auth manager does + * not pass it, and it is what adds the conditional + * `scimManagedConnection` / `scimManagedCredential` / `scimManagedConnectionEvent` + * trio to the declared schema — so configuring it here would make this gate + * compare a model surface no ObjectStack deployment provisions. + */ +const SCIM_GATE_OPTIONS = { + connections: [], + authentication: { verifyBearerToken: () => null }, +}; + +/** + * The scim plugin, or a failure that says which half broke. + * + * Construction happens at collection time — the `for` header below has to read + * the schema to emit one `it()` per model — so a constructor incompatibility + * can only ever be a collection failure. What it does not have to be is + * illegible: a raw vendor `TypeError` reads as "the gate is broken", which is + * how the last one cost a migration round. Re-throwing with the cause attached + * keeps the vendor error verbatim and says what to do with it. + */ +function constructScim(): { schema?: Record }> } { + try { + return scim(SCIM_GATE_OPTIONS as never) as never; + } catch (cause) { + // The vendor error is spelled INTO the message rather than passed as the + // `Error` constructor's second argument: this package compiles against + // `lib: es2020`, where `ErrorOptions` does not exist, so `new Error(msg, + // { cause })` is a TS2554 that would land in plugin-auth's shrink-only + // TEST_DEBT count. `cause` is still attached for a reader that unwraps it. + const vendor = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause); + throw Object.assign( + new Error( + 'the installed @better-auth/scim rejected this gate\'s constructor options, so the gate ' + + 'could not reach the model comparison it exists for (#11380). This is a CONSTRUCTOR ' + + 'mismatch, not a parity failure: update SCIM_GATE_OPTIONS above to a shape the installed ' + + `version accepts, then read the model diff the gate prints. Vendor error: ${vendor}`, + ), + { cause }, + ); + } +} + +/** The models `@better-auth/scim` declares on the installed version. */ +function scimSchema(): Record }> { + return constructScim().schema ?? {}; +} + /** * SCIM models with no platform object, acknowledged rather than silently * skipped. These four are SCIM **group** provisioning (`/Groups` push from the @@ -475,7 +554,7 @@ const KNOWN_UNMAPPED_MODELS = new Set([ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parity (#3653)', () => { const plugins: Array<{ label: string; schema: Record }> }> = [ { label: 'sso', schema: (sso() as any).schema }, - { label: 'scim', schema: (scim({} as never) as any).schema }, + { label: 'scim', schema: scimSchema() }, ]; it('are excluded from the getAuthTables() call for the reason this file states (#8122)', () => { @@ -483,7 +562,7 @@ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parit // wording expired silently under a version bump with every gate green // (#8224) — the drift shape the reconciliation above exists to stop, so the // reason these two sit outside that reconciliation is pinned here instead. - const derived = getAuthTables({ plugins: [sso(), scim({} as never)] } as never) as Record< + const derived = getAuthTables({ plugins: [sso(), constructScim()] } as never) as Record< string, { modelName?: string; fields?: Record } >; @@ -530,7 +609,7 @@ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parit for (const { label, schema } of [ { label: 'sso', schema: (sso() as any).schema as Record }> }, - { label: 'scim', schema: (scim({} as never) as any).schema as Record }> }, + { label: 'scim', schema: scimSchema() }, ]) { for (const [model, def] of Object.entries(schema ?? {})) { if (KNOWN_UNMAPPED_MODELS.has(model)) continue; From 7544c02b5baee5b1584c304174de78a17cda9e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:44:28 +0000 Subject: [PATCH 2/2] test(plugin-auth): record that the SCIM gate's cheap option shape compares the real surface The repair picked the cheapest options stable accepts. Cheapest is only safe if it does not narrow WHAT the gate compares, so that half is now measured rather than assumed: against the published 1.7.1, `connections: [] + authentication.verifyBearerToken` and a realistic static connection (`connections: [{ id, credentials: [{ type: 'bearer', id, token }] }]`) declare the SAME seven models with the SAME column sets. Also records why a connection entry is not the smaller input it looks like: validateConnections goes on to read `connection.credentials.length`, so a half-specified entry throws the same TypeError one line further down -- measured while probing this. Comment-only; no assertion or construction changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../plugin-auth/src/better-auth-schema-parity.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index f6d8386b7c..d60d80b040 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -487,6 +487,17 @@ function adapterColumn(field: string): string { * schema the plugin declares. Both keys are inert on the pinned rc.1, whose * factory spreads its options and reads only the keys it knows. * + * Cheapest is not a compromise on WHAT is compared, which is the half that + * would have made this repair worthless. Measured 2026-08-23 against the + * published 1.7.1: this shape and a realistic static connection + * (`connections: [{ id, credentials: [{ type: 'bearer', id, token }] }]`) + * declare the SAME seven models with the SAME column sets, so the surface this + * gate compares is the one a configured deployment gets rather than an artefact + * of the cheapest constructor call. A connection entry is also not the smaller + * input it looks like: `validateConnections` goes on to read + * `connection.credentials.length`, so a half-specified entry throws the same + * TypeError one line further down. + * * ⚠️ Deliberately NOT configured: `managedConnections`. The auth manager does * not pass it, and it is what adds the conditional * `scimManagedConnection` / `scimManagedCredential` / `scimManagedConnectionEvent`