From d43656823e4ba11e7cdbc69f9ca6d8e7484588d2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:31:23 +0800 Subject: [PATCH] fix(plugin-auth): pin better-auth family to 1.7.0-rc.1 in the published manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fresh `npx create-objectstack` (15.1.0) project shipped with auth returning 500 on sign-up / sign-in / get-session: "Cannot set properties of undefined (setting 'modelName')", thrown from @better-auth/oauth-provider@1.6.23 during better-auth init. The workspace forces the whole better-auth family to 1.7.0-rc.1 via pnpm-workspace.yaml overrides (GHSA-j8v8-g9cx-5qf4 is only fixed in the 1.7.0 pre-release line, see 602053cf4), so every CI job and showcase here ran a graph downstream installs can never reproduce: overrides do not ship with published packages, and plugin-auth still declared ^1.6.23 for better-auth / @better-auth/core / @better-auth/oauth-provider / @better-auth/sso. Downstream resolved those to 1.6.23 (still the npm `latest`), and the 1.7 scim + 1.6.23 rest mix crashes at startup. - Pin all four to the exact 1.7.0-rc.1 (same as @better-auth/scim) — the only combination this repo actually builds and tests. No `^` on the rc: npm's default resolution never selects a prerelease from a caret range, which is precisely how this drift shipped. Relax to ^1.7.0 once a stable better-auth 1.7.0 exists. - Workspace lockfile is unchanged by the pin (pnpm already resolved these to 1.7.0-rc.1 via the overrides); the overrides stay as belt-and-braces. - Add scripts/check-override-consistency.mjs + a validate-deps step: for every pnpm-workspace override whose package appears in a publishable package's dependencies/optionalDependencies/peerDependencies, the declared range must resolve to the override target under npm's default (no-prerelease) semantics, so tested-vs-published drift fails CI with a "overrides do not ship with published packages" hint instead of shipping silently. Needs semver, added as a root devDependency. Verified against the packed artifact: `pnpm pack` of this plugin-auth, installed into a fresh create-objectstack@15.1.0 project via a pnpm override — lockfile resolves the whole family (adapters + telemetry included) to 1.7.0-rc.1, startup log has zero "Failed to register OIDC discovery routes", and sign-up / sign-in / get-session all return 200. A control project without the override reproduces the three 500s. Co-Authored-By: Claude Fable 5 --- .../plugin-auth-pin-better-auth-family.md | 9 + .github/workflows/validate-deps.yml | 11 + package.json | 1 + packages/plugins/plugin-auth/package.json | 8 +- pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 29 +- scripts/check-override-consistency.mjs | 297 ++++++++++++++++++ 7 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 .changeset/plugin-auth-pin-better-auth-family.md create mode 100644 scripts/check-override-consistency.mjs diff --git a/.changeset/plugin-auth-pin-better-auth-family.md b/.changeset/plugin-auth-pin-better-auth-family.md new file mode 100644 index 0000000000..167cc2e8d9 --- /dev/null +++ b/.changeset/plugin-auth-pin-better-auth-family.md @@ -0,0 +1,9 @@ +--- +"@objectstack/plugin-auth": patch +--- + +Fix fresh-project auth returning 500 on every endpoint (sign-up / sign-in / get-session) with `Cannot set properties of undefined (setting 'modelName')`. + +The published manifest declared `better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, and `@better-auth/sso` as `^1.6.23`, while only `@better-auth/scim` was pinned to `1.7.0-rc.1` (GHSA-j8v8-g9cx-5qf4 is fixed only in the 1.7.0 pre-release line). The framework workspace forces the whole better-auth family to `1.7.0-rc.1` via pnpm overrides, but overrides do not ship with published packages — a downstream `npx create-objectstack` install resolved the `^1.6.23` ranges to 1.6.23 (still the npm `latest`), and the resulting 1.7/1.6 mix crashes during better-auth initialization, so every fresh 15.1.0 project shipped with broken auth. + +All four packages are now pinned to the exact `1.7.0-rc.1` — the only combination the workspace actually builds and tests against. The pins will be relaxed to `^1.7.0` once a stable better-auth 1.7.0 ships. A new CI gate (`scripts/check-override-consistency.mjs`) fails whenever a pnpm-workspace override target is not reachable from a publishable package's declared range, so tested-vs-published drift like this cannot recur silently. diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml index f402ceb5f3..3d5b93bcc1 100644 --- a/.github/workflows/validate-deps.yml +++ b/.github/workflows/validate-deps.yml @@ -8,6 +8,7 @@ on: - '.changeset/config.json' - 'pnpm-workspace.yaml' - 'scripts/check-changeset-fixed.mjs' + - 'scripts/check-override-consistency.mjs' # Re-run when the workflow itself changes, so edits to these gates are # exercised on the PR that introduces them. - '.github/workflows/validate-deps.yml' @@ -58,6 +59,16 @@ jobs: - name: Verify Changesets "fixed" group covers every public package run: node scripts/check-changeset-fixed.mjs + + # pnpm overrides pin what THIS workspace resolves, but they do not ship + # with published packages — downstream installs see only the declared + # ranges. If an override target isn't reachable from a publishable + # package's declared range, we test one dependency graph and publish + # another (the 15.1.0 quickstart shipped exactly that: plugin-auth + # declared better-auth ^1.6.23 while CI ran the 1.7.0-rc.1 override, + # and every fresh project 500'd on auth). + - name: Verify overrides are reflected in published manifests + run: node scripts/check-override-consistency.mjs # Fail the workflow if known vulnerabilities are found — enforces # security compliance before merging. diff --git a/package.json b/package.json index b8ea9369c9..abf4373811 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/node": "^26.1.1", "@typescript-eslint/parser": "^8.63.0", "eslint": "^10.7.0", + "semver": "^7.7.3", "svelte": "^5.55.7", "tsup": "^8.5.1", "tsx": "^4.23.0", diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index ad3c6a01f7..e7ef711ef4 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -18,17 +18,17 @@ "test": "vitest run" }, "dependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/oauth-provider": "^1.6.23", + "@better-auth/core": "1.7.0-rc.1", + "@better-auth/oauth-provider": "1.7.0-rc.1", "@better-auth/scim": "1.7.0-rc.1", - "@better-auth/sso": "^1.6.23", + "@better-auth/sso": "1.7.0-rc.1", "@noble/hashes": "^2.2.0", "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/rest": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "better-auth": "^1.6.23", + "better-auth": "1.7.0-rc.1", "jose": "^6.2.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c2e7a9a25..0f41c51876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) + semver: + specifier: ^7.7.3 + version: 7.8.5 svelte: specifier: ^5.55.7 version: 5.56.5(@typescript-eslint/types@8.63.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4175c78e36..9020bd56d4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,19 +30,26 @@ onlyBuiltDependencies: # @vscode/vsce > cheerio (declares undici ^7.19.0); force the patched # 7.28.0 line (stays in the 7.x major cheerio supports). CI audit gate. # - @better-auth/scim: GHSA-j8v8-g9cx-5qf4 (high) — account/provider -# takeover. `plugin-auth` depends on ^1.6.23 (the `latest` dist-tag), but -# the advisory is patched only in >=1.7.0-beta.4 — there is NO stable -# patched release yet. Pin to the newest pre-release (1.7.0-rc.1) to clear -# the CI audit gate; revert to a stable `^1.7.x` line the moment one ships. +# takeover. The advisory is patched only in >=1.7.0-beta.4 — there is NO +# stable patched release yet (npm `latest` is still 1.6.23). Pin to the +# newest pre-release (1.7.0-rc.1) to clear the CI audit gate; revert to a +# stable `^1.7.x` line the moment one ships. # - @better-auth/oauth-provider: GHSA-p2fr-6hmx-4528 — same better-auth -# monorepo and same situation as @better-auth/scim above. `plugin-auth` -# depends on ^1.6.23; the fix first ships in the 1.7.0 pre-release line. -# Pin to 1.7.0-rc.1. The 1.7 oauth-provider is exercised on the sign-in -# path and imports symbols (e.g. CLIENT_ASSERTION_TYPE) that only exist in -# @better-auth/core 1.7.x, so the ENTIRE better-auth family must move to -# 1.7.0-rc.1 together — mixing a 1.7 plugin with 1.6.23 core 500s at -# runtime (dogfood sign-in). The full family is pinned below; revert all of +# monorepo and same situation as @better-auth/scim above. The fix first +# ships in the 1.7.0 pre-release line. Pin to 1.7.0-rc.1. The 1.7 +# oauth-provider is exercised on the sign-in path and imports symbols +# (e.g. CLIENT_ASSERTION_TYPE) that only exist in @better-auth/core 1.7.x, +# so the ENTIRE better-auth family must move to 1.7.0-rc.1 together — +# mixing a 1.7 plugin with 1.6.23 core throws "Cannot set properties of +# undefined (setting 'modelName')" during better-auth init and 500s every +# auth endpoint at runtime. The full family is pinned below; revert all of # them to a stable `^1.7.x` line the moment one ships. +# IMPORTANT: these overrides do NOT ship with published packages — a +# downstream `npx create-objectstack` install resolves plugin-auth's own +# declared ranges. plugin-auth therefore pins the same exact 1.7.0-rc.1 in +# its dependencies (a `^1.6.23` range there resolved to the broken 1.6.23 +# mix and 500'd every fresh 15.1.0 project). Keep both in sync — CI +# enforces this via scripts/check-override-consistency.mjs. # - uuid: GHSA-w5hq-g745-h8pq (high) — pulled 8.3.2 transitively; the fix # first lands in 11.1.1. Pin to the ^11.1.1 LTS line (uuid `legacy-11` # dist-tag) rather than the latest major to keep the jump conservative. diff --git a/scripts/check-override-consistency.mjs b/scripts/check-override-consistency.mjs new file mode 100644 index 0000000000..06e8de798f --- /dev/null +++ b/scripts/check-override-consistency.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +/** + * Validates that pnpm-workspace.yaml `overrides` are reflected in the + * manifests of publishable packages. + * + * Run: node scripts/check-override-consistency.mjs + * + * Why: pnpm overrides apply ONLY inside this workspace — they do not ship + * with published packages. If an override forces `better-auth` to + * 1.7.0-rc.1 but @objectstack/plugin-auth still declares `^1.6.23`, every + * CI job and showcase in this repo runs the overridden (tested) version + * while a downstream `npx create-objectstack` install resolves the declared + * range to a combination that was never tested here (the 15.1.0 quickstart + * shipped exactly this: auth 500'd on every fresh project). + * + * For every override whose package name appears in a publishable package's + * `dependencies` / `optionalDependencies` / `peerDependencies`, the declared + * range must resolve to the override target under npm's default semantics + * (no implicit prereleases — the same rules a downstream install uses): + * - exact target version V → semver.satisfies(V, declaredRange) + * - target range R → semver.intersects(declaredRange, R) + * Declarations that can never be rewritten by the override (their range does + * not intersect the override's `pkg@selector` scope) are skipped: workspace + * and downstream already resolve those identically. + * + * Uses the `semver` package (root devDependency), so it must run after + * `pnpm install` — which is how the validate-deps workflow orders it. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import semver from 'semver'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); + +/** + * Minimal pnpm-workspace.yaml block parsers (same approach as + * scripts/check-changeset-fixed.mjs): this repo's file only uses simple + * `key: value` scalars and `- item` lists, so a YAML dependency is avoided. + */ +function readWorkspaceYamlLines() { + const text = readFileSync(resolve(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); + return text.split(/\r?\n/); +} + +/** @returns {string[]} */ +function readWorkspacePatterns() { + const patterns = []; + let inPackages = false; + for (const raw of readWorkspaceYamlLines()) { + const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); + if (!line.trim()) continue; + if (/^packages\s*:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (inPackages) { + const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); + if (m) { + patterns.push(m[1]); + continue; + } + if (/^\S/.test(line)) inPackages = false; + } + } + return patterns; +} + +/** + * @returns {Array<{ name: string, selector: string | null, target: string }>} + * One entry per `overrides:` line. `selector` is the optional range scope + * in an `'pkg@': target` key; `target` is the forced version/range. + */ +function readOverrides() { + const overrides = []; + let inOverrides = false; + for (const raw of readWorkspaceYamlLines()) { + const line = raw.replace(/\s+$/, ''); + if (!line.trim() || /^\s*#/.test(line)) continue; + if (/^overrides\s*:\s*$/.test(line)) { + inOverrides = true; + continue; + } + if (!inOverrides) continue; + if (/^\S/.test(line)) { + inOverrides = false; + continue; + } + const m = /^\s+["']?([^"':]+?)["']?\s*:\s*["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line); + if (!m) continue; + const key = m[1].trim(); + const target = m[2].trim(); + // Split `pkg@selector` on the LAST `@` so scoped names survive. + const at = key.lastIndexOf('@'); + if (at > 0) { + overrides.push({ name: key.slice(0, at), selector: key.slice(at + 1), target }); + } else { + overrides.push({ name: key, selector: null, target }); + } + } + return overrides; +} + +/** @param {string} pattern @returns {string[]} */ +function expandPattern(pattern) { + const segments = pattern.split('/'); + let dirs = [repoRoot]; + for (const seg of segments) { + const next = []; + for (const dir of dirs) { + if (seg === '*') { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) { + next.push(join(dir, entry.name)); + } + } + } else { + const candidate = join(dir, seg); + try { + if (statSync(candidate).isDirectory()) next.push(candidate); + } catch { + /* missing - skip */ + } + } + } + dirs = next; + } + return dirs; +} + +/** @returns {Array<{ dir: string, pkg: any }>} all non-private workspace packages */ +function listPublishablePackages() { + const seen = new Set(); + const result = []; + for (const pattern of readWorkspacePatterns()) { + for (const dir of expandPattern(pattern)) { + let pkg; + try { + pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + } catch { + continue; + } + if (!pkg.name || pkg.private === true) continue; + if (seen.has(pkg.name)) continue; + seen.add(pkg.name); + result.push({ dir, pkg }); + } + } + return result; +} + +/** + * Resolve a manifest entry to the package name + range npm would install. + * Returns null for entries that are rewritten at publish time or are not + * registry ranges (workspace:, catalog:, file:, link:, git/urls, tags). + * + * @param {string} declaredName @param {string} declaredValue + * @returns {{ name: string, range: string } | null} + */ +function toRegistryRange(declaredName, declaredValue) { + let name = declaredName; + let range = declaredValue; + if (range.startsWith('npm:')) { + // Alias: `alias: npm:real-name@range` + const rest = range.slice('npm:'.length); + const at = rest.lastIndexOf('@'); + if (at <= 0) return null; + name = rest.slice(0, at); + range = rest.slice(at + 1); + } + if (/^(workspace|catalog|file|link|git|github):/.test(range)) return null; + if (semver.validRange(range) === null) return null; // tags, urls + return { name, range }; +} + +/** + * Would the override rewrite anything this range can resolve to? If the + * declared range cannot intersect the override's selector scope, workspace + * and downstream installs already agree and the check does not apply. + */ +function overrideApplies(override, declaredRange) { + if (override.selector === null) return true; + if (semver.validRange(override.selector, { includePrerelease: true }) === null) return true; + try { + return semver.intersects(declaredRange, override.selector, { includePrerelease: true }); + } catch { + return true; // be conservative: unparseable → keep checking + } +} + +/** + * Downstream (no overrides, npm default prerelease rules): does the declared + * range resolve to the override target? + */ +function declarationMatchesTarget(declaredRange, target) { + if (semver.valid(target) !== null) { + return semver.satisfies(target, declaredRange); + } + if (semver.validRange(target) !== null) { + try { + return semver.intersects(declaredRange, target); + } catch { + return false; + } + } + return false; +} + +function main() { + const overrides = readOverrides(); + if (overrides.length === 0) { + console.log('✓ No overrides in pnpm-workspace.yaml — nothing to check.'); + return; + } + const overridesByName = new Map(); + for (const o of overrides) { + const list = overridesByName.get(o.name) ?? []; + list.push(o); + overridesByName.set(o.name, list); + } + + const FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies']; + const violations = []; + let checked = 0; + + for (const { dir, pkg } of listPublishablePackages()) { + for (const field of FIELDS) { + for (const [depName, depValue] of Object.entries(pkg[field] ?? {})) { + if (typeof depValue !== 'string') continue; + const entry = toRegistryRange(depName, depValue); + if (!entry) continue; + const candidates = overridesByName.get(entry.name); + if (!candidates) continue; + for (const override of candidates) { + if (!overrideApplies(override, entry.range)) continue; + checked += 1; + if (declarationMatchesTarget(entry.range, override.target)) continue; + violations.push({ + pkgName: pkg.name, + manifest: relative(repoRoot, join(dir, 'package.json')), + field, + depName, + declared: depValue, + override, + }); + } + } + } + } + + if (violations.length === 0) { + console.log( + `✓ ${checked} published-manifest declaration(s) covered by pnpm-workspace.yaml overrides all resolve to their override targets.`, + ); + return; + } + + console.error('✗ pnpm-workspace.yaml overrides are not reflected in published manifests.'); + console.error( + '\npnpm overrides apply only inside this workspace — they do NOT ship with', + ); + console.error( + 'published packages. Downstream installs resolve the declared range below to', + ); + console.error( + 'a different version than every workspace build/test actually ran against:', + ); + for (const v of violations) { + const key = v.override.selector + ? `${v.override.name}@${v.override.selector}` + : v.override.name; + console.error( + `\n - ${v.pkgName} (${v.manifest})\n` + + ` ${v.field}.${v.depName}: "${v.declared}"\n` + + ` override: '${key}': '${v.override.target}'\n` + + ` → downstream resolves "${v.declared}" WITHOUT the override; update the\n` + + ` declared range so it resolves to ${v.override.target} (or drop the override).`, + ); + } + console.error( + '\nFix: sync the declared range with the override target in the package manifest,', + ); + console.error( + 'then re-run: node scripts/check-override-consistency.mjs', + ); + process.exit(1); +} + +main();