From 340924191bfc64a4ab85496f218435a0fddd5004 Mon Sep 17 00:00:00 2001 From: Jack Zhuang Date: Mon, 7 Sep 2026 21:37:44 +0800 Subject: [PATCH 1/4] fix(plugin-auth): pin the better-auth family to an exact 1.7.2 (#16186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@better-auth/core@1.7.3` deleted `createLocalAccountIssuer` and `createOAuthAccountIssuer` from `@better-auth/core/db` — and the whole `account.issuer` column with them — in a PATCH release. `plugin-auth` imports both names statically, so every consumer of published 17.1.0 / 17.2.0 / 17.3.0 resolved 1.7.3 through the declared `^1.7.2` range and could not load the plugin at all. This repo's lockfile held 1.7.2, so every CI job here was green while the artifact it produced was broken on install. The declared range and the workspace override target both become exact 1.7.2, so the version this repo tests is the version a consumer resolves. Resolutions are unchanged: the lockfile already held 1.7.2 for all eleven family members, and `pnpm install --lockfile-only` moves only the echoed specifier strings. Co-Authored-By: Claude Opus 5 --- packages/plugins/plugin-auth/package.json | 10 +-- pnpm-lock.yaml | 56 ++++++------ pnpm-workspace.yaml | 100 ++++++++++++++-------- 3 files changed, 99 insertions(+), 67 deletions(-) diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 8ab88d1d89..7ced877e7e 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -26,10 +26,10 @@ "gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-auth --project tsconfig.test.json" }, "dependencies": { - "@better-auth/core": "^1.7.2", - "@better-auth/oauth-provider": "^1.7.2", - "@better-auth/scim": "^1.7.2", - "@better-auth/sso": "^1.7.2", + "@better-auth/core": "1.7.2", + "@better-auth/oauth-provider": "1.7.2", + "@better-auth/scim": "1.7.2", + "@better-auth/sso": "1.7.2", "@noble/hashes": "^2.3.0", "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", @@ -37,7 +37,7 @@ "@objectstack/service-messaging": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "better-auth": "^1.7.2", + "better-auth": "1.7.2", "jose": "^6.2.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70fa8ee2bf..1d96b07058 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,17 +10,17 @@ overrides: tar@>=2.0.0 <8.0.0: ^7.5.11 form-data@<5.0.0: '>=4.0.6' undici@>=7.23.0 <8.0.0: ^7.29.0 - better-auth@<2.0.0: ^1.7.2 - '@better-auth/core@<2.0.0': ^1.7.2 - '@better-auth/scim@<2.0.0': ^1.7.2 - '@better-auth/oauth-provider@<2.0.0': ^1.7.2 - '@better-auth/sso@<2.0.0': ^1.7.2 - '@better-auth/drizzle-adapter@<2.0.0': ^1.7.2 - '@better-auth/kysely-adapter@<2.0.0': ^1.7.2 - '@better-auth/memory-adapter@<2.0.0': ^1.7.2 - '@better-auth/mongo-adapter@<2.0.0': ^1.7.2 - '@better-auth/prisma-adapter@<2.0.0': ^1.7.2 - '@better-auth/telemetry@<2.0.0': ^1.7.2 + better-auth@<2.0.0: 1.7.2 + '@better-auth/core@<2.0.0': 1.7.2 + '@better-auth/scim@<2.0.0': 1.7.2 + '@better-auth/oauth-provider@<2.0.0': 1.7.2 + '@better-auth/sso@<2.0.0': 1.7.2 + '@better-auth/drizzle-adapter@<2.0.0': 1.7.2 + '@better-auth/kysely-adapter@<2.0.0': 1.7.2 + '@better-auth/memory-adapter@<2.0.0': 1.7.2 + '@better-auth/mongo-adapter@<2.0.0': 1.7.2 + '@better-auth/prisma-adapter@<2.0.0': 1.7.2 + '@better-auth/telemetry@<2.0.0': 1.7.2 uuid@<12.0.0: ^11.1.1 postcss@<9.0.0: ^8.5.10 cookie@<0.8.0: ^0.7.0 @@ -1589,16 +1589,16 @@ importers: packages/plugins/plugin-auth: dependencies: '@better-auth/core': - specifier: ^1.7.2 + specifier: 1.7.2 version: 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0) '@better-auth/oauth-provider': - specifier: ^1.7.2 + specifier: 1.7.2 version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.10))(better-call@1.4.0(zod@4.4.3)) '@better-auth/scim': - specifier: ^1.7.2 + specifier: 1.7.2 version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.10))(better-call@1.4.0(zod@4.4.3)) '@better-auth/sso': - specifier: ^1.7.2 + specifier: 1.7.2 version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.10))(better-call@1.4.0(zod@4.4.3)) '@noble/hashes': specifier: ^2.3.0 @@ -1622,7 +1622,7 @@ importers: specifier: workspace:* version: link:../../types better-auth: - specifier: ^1.7.2 + specifier: 1.7.2 version: 1.7.2(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(mongodb@7.5.0(socks@2.8.9))(mysql2@3.23.3(@types/node@26.2.0))(next@16.3.1(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vitest@4.1.10) jose: specifier: ^6.2.8 @@ -3193,7 +3193,7 @@ packages: '@better-auth/drizzle-adapter@1.7.2': resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: @@ -3203,7 +3203,7 @@ packages: '@better-auth/kysely-adapter@1.7.2': resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: @@ -3213,13 +3213,13 @@ packages: '@better-auth/memory-adapter@1.7.2': resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 '@better-auth/mongo-adapter@1.7.2': resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -3229,16 +3229,16 @@ packages: '@better-auth/oauth-provider@1.7.2': resolution: {integrity: sha512-td7FnUz3lLKXFXN+0RbZe3ygaHqxpRqDG+gxbfSZbztbVfG7vuZtR4ba3uccsodAw7anchhIg2xwVP1/zlcxcw==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: ^1.7.2 + better-auth: 1.7.2 better-call: 1.4.0 '@better-auth/prisma-adapter@1.7.2': resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3251,24 +3251,24 @@ packages: '@better-auth/scim@1.7.2': resolution: {integrity: sha512-RBwVN189w37xwK0n+mRgARTmJFCriOsMVCbe7Bzeyak6esSg9kTGeCUmGtfgwOKJ3jJWQkeAQdWw7VRJ/HOG6w==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 - better-auth: ^1.7.2 + better-auth: 1.7.2 better-call: 1.4.0 '@better-auth/sso@1.7.2': resolution: {integrity: sha512-8tmkAGdcVu8Tr/+LPfSRgs/5a8YE1uU8OeaN5mAzsSdMWR4iwZdwQlJJmU8f9qZyIpNL/B6mIfDc5vaVUy1ECQ==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: ^1.7.2 + better-auth: 1.7.2 better-call: 1.4.0 '@better-auth/telemetry@1.7.2': resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': 1.7.2 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db75bcf0ba..406aeb659c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,7 +25,12 @@ onlyBuiltDependencies: # ⛔ SELECTOR SHAPE — the one rule every OSV pin below now follows (#6095). # An OSV pin states a FLOOR ("nothing below the patched line"), so its selector # must cover the whole major it guards and its target must be a range that -# floats up inside that major. Concretely: +# floats up inside that major — UNLESS the package's export surface is one we +# compile against, in which case the target is an EXACT version and lifting it +# is a reviewed edit (the better-auth family, #16186: 1.7.3 deleted a public +# export in a PATCH). The selector rule below is unchanged either way, and it +# is what keeps an exact target a one-line lift. +# Concretely: # 'pkg@>= ': '^' # Never write the selector's exclusive upper bound AT the target's own version # line (`pkg@ `^X.Y.Z`). That shape self-invalidates the day X.Y.Z @@ -43,9 +48,10 @@ onlyBuiltDependencies: # the three zero-consumer pins awaiting a #5835-style ruling # (@tootallnate/once, react-router, @sveltejs/kit). (`@better-auth/scim` was # the other carve-out while it held the rc; #3653 moved it onto stable with -# the bound at the major boundary, and #13715 returned its TARGET to the -# family's `^` — see its note below. Both are about the resolved version, not -# the selector shape this rule governs.) +# the bound at the major boundary, #13715 returned its TARGET to the family's +# `^`, and #16186 moved the whole family back to an EXACT target — see its +# note below. All three are about the resolved version, not the selector shape +# this rule governs.) # - esbuild: GHSA-gv7w-rqvm-qjhr (high). tsup/tsx/vite pulled 0.27.7 / 0.28.0 # (< 0.28.1); force the patched line everywhere. # - form-data: GHSA-hmw2-7cc7-3qxx (high) — CRLF injection via unescaped @@ -135,10 +141,15 @@ onlyBuiltDependencies: # undici above). # 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 declares the same `^1.7.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. +# declared ranges. plugin-auth therefore declares the same exact `1.7.2` +# 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; a `^1.7.2` range there +# resolved to 1.7.3 and made every fresh 17.1.0–17.3.0 project unloadable, +# #16186). Keep both in sync — CI enforces this via +# scripts/check-override-consistency.mjs, and +# scripts/check-vendor-export-contract.mjs enforces that the DECLARED +# range admits exactly one version and that version exports every symbol +# plugin-auth imports. # - 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. @@ -165,10 +176,30 @@ overrides: 'undici@>=7.23.0 <8.0.0': '^7.29.0' # better-auth family — kept on one line (see @better-auth/oauth-provider note). # Off the 1.7.0-rc.2 prerelease and onto the stable line (#3002). Bounds sit - # at the MAJOR boundary, so a future advisory lift moves only the target. - 'better-auth@<2.0.0': '^1.7.2' - '@better-auth/core@<2.0.0': '^1.7.2' - # scim carries the family's `^` again — see the @better-auth/scim note + # at the MAJOR boundary, so a future advisory lift moves only the TARGET. + # + # ⛔ EXACT TARGETS, not `^`, since #16186. This family removes public exports + # in PATCH releases: 1.7.3 deleted `createLocalAccountIssuer` / + # `createOAuthAccountIssuer` and the whole `account.issuer` column from + # `@better-auth/core/db` (better-auth/better-auth#10909 rolled the + # issuer-scoped account identity back), and `@objectstack/plugin-auth` + # statically imports both names. A caret cannot express "the export surface + # we compile against", so `^` here means the tested version and the shipped + # version are free to differ — which is exactly what happened: this + # lockfile held 1.7.2 and every CI job was green while every consumer of + # published 17.1.0–17.3.0 resolved 1.7.3 and could not load the plugin at + # all. The selectors keep their `<2.0.0` major boundary, so lifting the + # family later is still a target-only edit. + # + # These targets are held EQUAL to the ranges `@objectstack/plugin-auth` + # declares (`scripts/check-override-consistency.mjs` cross-checks that the + # declared range admits the target; `pnpm check:vendor-export-contract` + # requires the declared range to be exact and to export what we import). + # Move all eleven together, in one commit, or better-auth init throws and + # every auth endpoint 500s. + 'better-auth@<2.0.0': '1.7.2' + '@better-auth/core@<2.0.0': '1.7.2' + # scim carries the family's target — see the @better-auth/scim note # above. It was held at 1.7.1 EXACT, one deliberate step behind `^1.7.1`, # because `^1.7.1` then resolved scim to 1.7.2 while the installed family # was still 1.7.1: scim 1.7.2 peers `better-auth`/`@better-auth/core` at @@ -183,29 +214,30 @@ overrides: # for all eleven; the install resolves all eleven to 1.7.2 (one copy each), # so scim's `^1.7.2` peers are SATISFIED by the installed 1.7.2 pair rather # than silenced — the condition the exact hold existed for no longer holds. - # `^` rather than a fresh exact pin for two reasons. Structural: the two - # sibling standalone plugins, oauth-provider and sso, peer the family in - # exactly the same shape (`better-auth`/`@better-auth/core` at `^1.7.2`) and - # carry `^`; an exact scim would be the one asymmetric member with no reason - # left to state. Security: this pin is also the GHSA-j8v8-g9cx-5qf4 floor, - # and a floor that cannot take the next patch is the wrong shape for a - # package with that history. Measured, so it is not a preference: `^1.7.2` - # and exact `1.7.2` produce byte-identical resolutions today — two - # `pnpm install --lockfile-only` runs differ only in the echoed override - # string and the echoed importer specifier, in no resolved version and no - # peer-resolution suffix — so the shape is chosen on durability, not on a - # resolution difference that does not exist. + # It carried `^1.7.2` from #13715 until #16186, on two arguments the 1.7.3 + # release answered. Structural — "the siblings peer `^1.7.2` and carry `^`, + # so an exact scim would be the one asymmetric member" — is moot now that + # ALL eleven are exact; the family is symmetric again, one line lower. + # Security — "this pin is also the GHSA-j8v8-g9cx-5qf4 floor, and a floor + # that cannot take the next patch is the wrong shape" — was the reasoning + # 1.7.3 refuted: taking the next patch UNREVIEWED is what a floor must not + # do when the vendor deletes public exports in one. 1.7.2 is above the + # patched line for GHSA-j8v8-g9cx-5qf4 and GHSA-p2fr-6hmx-4528, so the floor + # still holds; what changed is that lifting it is now a reviewed edit, which + # is the only way an export-surface change gets read before it ships. That + # review has somewhere to land: `pnpm check:vendor-export-contract` fails on + # a lift whose new version drops a symbol `plugin-auth` imports. # What stays true: the whole family still moves as ONE line, and a bump that - # moves scim alone is still the mistake the ruling named. - '@better-auth/scim@<2.0.0': '^1.7.2' - '@better-auth/oauth-provider@<2.0.0': '^1.7.2' - '@better-auth/sso@<2.0.0': '^1.7.2' - '@better-auth/drizzle-adapter@<2.0.0': '^1.7.2' - '@better-auth/kysely-adapter@<2.0.0': '^1.7.2' - '@better-auth/memory-adapter@<2.0.0': '^1.7.2' - '@better-auth/mongo-adapter@<2.0.0': '^1.7.2' - '@better-auth/prisma-adapter@<2.0.0': '^1.7.2' - '@better-auth/telemetry@<2.0.0': '^1.7.2' + # moves scim alone is still the mistake the #3653 ruling named. + '@better-auth/scim@<2.0.0': '1.7.2' + '@better-auth/oauth-provider@<2.0.0': '1.7.2' + '@better-auth/sso@<2.0.0': '1.7.2' + '@better-auth/drizzle-adapter@<2.0.0': '1.7.2' + '@better-auth/kysely-adapter@<2.0.0': '1.7.2' + '@better-auth/memory-adapter@<2.0.0': '1.7.2' + '@better-auth/mongo-adapter@<2.0.0': '1.7.2' + '@better-auth/prisma-adapter@<2.0.0': '1.7.2' + '@better-auth/telemetry@<2.0.0': '1.7.2' 'uuid@<12.0.0': '^11.1.1' 'postcss@<9.0.0': '^8.5.10' 'cookie@<0.8.0': '^0.7.0' From 82fcd0fe80599b06c5a518c9aae41ac376f97110 Mon Sep 17 00:00:00 2001 From: Jack Zhuang Date: Mon, 7 Sep 2026 21:44:02 +0800 Subject: [PATCH 2/4] test(scripts): add the consumer-resolution regression control (#16186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning the version fixes today's break and leaves the repository exactly as blind as it was: `pnpm-lock.yaml` held `@better-auth/core@1.7.2`, so every job here imported the version that still had `createLocalAccountIssuer` while every consumer resolved `^1.7.2` to 1.7.3 and could not load the plugin at all. `scripts/check-vendor-export-contract.mjs` closes that class. For every governed vendor a publishable package statically imports runtime values from, it asserts the declared range is an EXACT version, that the installed copy IS that version, and that importing each specifier really yields every symbol our shipped source takes from it — so "what CI imported" and "what a consumer resolves" are the same string. `--resolve` measures the same property instead of proving it: it enumerates every registry version the DECLARED range admits, installs each outside this workspace, and fails if any is missing a symbol we import. Wired into validate-deps.yml, which already has the network and runs daily; the offline leg runs in the lint farm on every PR. `check-override-consistency.mjs` structurally cannot cover this — it asks whether the override target is reachable from the declared range, and `^1.7.2` agreed with `^1.7.2` all the way onto the broken version. Co-Authored-By: Claude Opus 5 --- .github/workflows/lint.yml | 18 + .github/workflows/validate-deps.yml | 26 + package.json | 2 + scripts/check-vendor-export-contract.mjs | 576 +++++++++++++++++++++++ 4 files changed, 622 insertions(+) create mode 100644 scripts/check-vendor-export-contract.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 130c893dba..376e587638 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2615,6 +2615,24 @@ jobs: - name: Auth mount-vs-ledger guard run: pnpm check:auth-mount-ledger + # Vendor export contract (#16186). `@better-auth/core@1.7.3` deleted + # `createLocalAccountIssuer` in a PATCH; plugin-auth imports it statically + # through a caret range, so published 17.1.0-17.3.0 could not load at all + # on a fresh install -- while every job here stayed green, because + # pnpm-lock.yaml held 1.7.2. The lockfile protected the producer from the + # defect it was shipping. This gate asserts that a governed vendor's + # DECLARED range is exact, that the installed version IS that version, and + # that importing it really yields every symbol our shipped source takes + # from it -- so "what CI imported" and "what a consumer resolves" are the + # same string. Cheap and offline: it imports what `pnpm install` already + # put on disk. The network half (`--resolve`, which enumerates every + # registry version the range admits) runs in validate-deps.yml, which + # already installs from the registry. No `paths:` filter: a manifest edit, + # a lockfile refresh and a new vendor import can each move it. Runs its + # own --self-test first. + - name: Vendor export contract + run: pnpm check:vendor-export-contract + # Optional-`error` sink contract (#9754, wired by #10608). A sink TYPE that # declares `error` as OPTIONAL must also declare `warn` as NON-optional, so # every value of that type has somewhere to put a durability report — diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml index ded2085fd5..0f49514e30 100644 --- a/.github/workflows/validate-deps.yml +++ b/.github/workflows/validate-deps.yml @@ -9,6 +9,9 @@ on: - 'pnpm-workspace.yaml' - 'scripts/check-changeset-fixed.mjs' - 'scripts/check-override-consistency.mjs' + # The consumer-resolution half of the same question (#16186) — an edit to + # the gate must be exercised on the PR that makes it. + - 'scripts/check-vendor-export-contract.mjs' # The OSV exemption ledger and its check: a PR that touches either must # run this workflow, or an exemption could be added without the gate # that governs it ever running on the PR that adds it. @@ -83,6 +86,29 @@ jobs: - name: Verify overrides are reflected in published manifests run: pnpm check:override-consistency + # The half check-override-consistency structurally cannot cover (#16186). + # It asks whether the override TARGET is reachable from the declared + # range; `^1.7.2` and `^1.7.2` agreed perfectly while both floated onto + # `@better-auth/core@1.7.3`, a PATCH that deleted the export plugin-auth + # imports statically. Three published releases could not load on a fresh + # install and nothing here went red, because pnpm-lock.yaml held 1.7.2. + # + # `--resolve` deliberately ignores the lockfile: for every version the + # DECLARED range admits on the registry -- the way a downstream project + # resolves -- it installs that version outside this workspace and checks + # the export surface. It fails if ANY admitted version is missing a symbol + # we import. With the ranges pinned exact that is one small install per + # vendor; under a caret it is one per published version, which is the cost + # of the risk being taken. + # + # It lives HERE rather than in the lint farm because it needs the network, + # which this job already has, and because the daily schedule is what turns + # "a vendor published something today" into a red in this repo instead of + # in a customer's install. An unreachable registry exits 3 + # (PREREQUISITE NOT MET), never a green. + - name: Verify the declared ranges cannot resolve past our import surface + run: pnpm check:vendor-export-contract-resolve + # Fail the workflow if known vulnerabilities are found — enforces # security compliance before merging. # diff --git a/package.json b/package.json index b84bdca13c..e4580ec68b 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,8 @@ "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test", "check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs", + "check:vendor-export-contract": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs", + "check:vendor-export-contract-resolve": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs --resolve", "check:release-notes": "node scripts/check-release-notes.mjs", "check:release-page-status": "node scripts/check-release-page-status.mjs --self-test && node scripts/check-release-page-status.mjs", "check:release-body": "node scripts/release-github-releases.mjs --self-test", diff --git a/scripts/check-vendor-export-contract.mjs b/scripts/check-vendor-export-contract.mjs new file mode 100644 index 0000000000..3553477b38 --- /dev/null +++ b/scripts/check-vendor-export-contract.mjs @@ -0,0 +1,576 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-vendor-export-contract — the version a CONSUMER resolves must export + * every symbol our published source statically imports from it. + * + * node scripts/check-vendor-export-contract.mjs [--resolve] [--self-test] + * + * ## The defect (#16186) + * + * `@objectstack/plugin-auth` compiles this line into its shipped `dist`: + * + * import { createLocalAccountIssuer, createOAuthAccountIssuer } + * from '@better-auth/core/db'; + * + * and declared `"@better-auth/core": "^1.7.2"`. `@better-auth/core@1.7.3` — a + * PATCH — deleted both names, and the `account.issuer` column behind them, + * because upstream rolled the issuer-scoped account identity back + * (better-auth/better-auth#10909). A static ESM named import of a missing + * export is a link-time `SyntaxError`, so the plugin could not load AT ALL: + * published 17.1.0, 17.2.0 and 17.3.0 never created a system table and never + * seeded, on every fresh install, for three releases. + * + * ## Why nothing in this repository noticed for three releases + * + * `pnpm-lock.yaml` held `@better-auth/core@1.7.2`. Every job in this repo + * therefore imported the version that still had the symbol and was green, + * while every consumer — who has no lockfile of ours — resolved `^1.7.2` to + * 1.7.3 and got the SyntaxError. **The lockfile protected the producer from + * the defect it was shipping.** Pinning the version fixes today's break; it + * does not fix that, and the next vendor patch would do it again. + * + * `scripts/check-override-consistency.mjs` is the neighbouring gate and does + * NOT cover this: it asks whether the workspace override target is REACHABLE + * from the declared range. `^1.7.2` and `^1.7.2` agreed perfectly while both + * floated onto a version nothing here had ever imported. + * + * ## What this gate asserts + * + * For every governed vendor package (`GOVERNED_VENDORS`) that a PUBLISHABLE + * workspace package statically imports runtime values from: + * + * 1. some publishable manifest DECLARES it — we never import a runtime + * symbol out of a package no shipped manifest names; + * 2. every declaring range is an EXACT version and they all agree — a range + * that admits more than one version cannot state which export surface we + * compiled against, and semver does not protect the surface anyway (the + * removal above shipped in a patch); + * 3. the version installed in this workspace IS that version — so "what CI + * imported" and "what a consumer resolves" are the same string; and + * 4. importing each specifier really yields every named symbol our source + * takes from it. + * + * (2) + (3) are what make (4) a statement about consumers rather than about + * our lockfile: an exact range resolves to exactly one version everywhere, so + * the surface this gate just verified is the surface a fresh install gets. + * That is the property the card asked for, proved rather than measured — and + * `--resolve` measures it too. + * + * ## `--resolve` — the network leg + * + * `--resolve` enumerates EVERY registry version satisfying each declared range + * (no lockfile involved, exactly how npm resolves for a downstream project), + * installs each one into a scratch project outside this workspace, and checks + * the export surface there. It fails if ANY satisfying version is missing a + * symbol we import — the literal reading of "the declared range can resolve to + * a version missing what we import". Under (2) that is a single version and + * costs one small install; under a caret it is however many the vendor has + * published, which is the cost of the risk being taken. + * + * It is opt-in because it needs the network: wired into `validate-deps.yml`, + * which already installs from the registry, and not into the PR lint farm. + * ⛔ It never "passes" when it cannot measure — an unreachable registry is + * EXIT_PREREQUISITE_NOT_MET, never a green. + * + * ## Scope: why a ledger of vendor NAMES and not every dependency + * + * Requiring an exact pin on every third-party range would be a different + * decision with a real cost (a release for every upstream patch). This gate + * governs the vendors whose export surface we compile against AND which have + * been observed to remove public exports without a major bump. Add a name to + * `GOVERNED_VENDORS` with the evidence, never a blanket rule. + * + * ⛔ Type-only imports are deliberately out of scope: they are erased before + * runtime, so a missing type is a `typecheck` finding, not a load failure. + * `import type { … }` and inline `type` specifiers are skipped. + * + * ⛔ Test files are out of scope for the same reason the published artifact is + * the subject here: a test importing a vendor symbol fails in `pnpm test`, + * loudly, on the same install. What this gate exists to catch is the failure + * that is invisible in-repo. + */ + +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { workspacePackages } from './workspace-enumerator.mjs'; +import { EXIT_PREREQUISITE_NOT_MET, requireDefaultExport } from './import-prerequisite.mjs'; + +const semver = await requireDefaultExport('semver', () => import('semver'), import.meta.url); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); + +/** + * Vendor package names — or `@scope/` prefixes — whose runtime export surface + * this repo compiles against and whose declared range must therefore pin one + * version. Every entry states the evidence that put it here. + */ +export const GOVERNED_VENDORS = [ + { + match: (name) => name === 'better-auth' || name.startsWith('@better-auth/'), + label: 'the better-auth family', + evidence: + '#16186 — @better-auth/core@1.7.3 (a PATCH) deleted createLocalAccountIssuer / ' + + 'createOAuthAccountIssuer from the declared `./db` exports subpath, which made ' + + 'published @objectstack/plugin-auth 17.1.0-17.3.0 unloadable on every fresh install.', + }, +]; + +/** @returns {boolean} whether `name` is a governed vendor package. */ +export function isGovernedVendor(name) { + return GOVERNED_VENDORS.some((v) => v.match(name)); +} + +/** + * The package name a bare module specifier belongs to. + * `@better-auth/core/db` -> `@better-auth/core`; `better-auth/adapters` -> + * `better-auth`. Returns null for relative and absolute specifiers. + */ +export function packageNameOf(specifier) { + if (!specifier || specifier.startsWith('.') || specifier.startsWith('/')) return null; + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +} + +/** + * Static VALUE imports only. + * + * Deliberately hand-rolled rather than TypeScript-AST based: this gate has to + * run on a fresh worktree in the lint farm, and its finding is about text a + * regex reads exactly as well. Three shapes carry runtime symbols — + * `import { a, b } from`, `import d from`, `import * as ns from` — and the + * fourth, `import 'x'`, imports no symbol but still LINKS the module, so it is + * recorded with an empty symbol set. + * + * @returns {Array<{ specifier: string, symbols: string[] }>} + */ +export function parseStaticValueImports(source) { + const found = new Map(); + const add = (specifier, symbols) => { + const prev = found.get(specifier) ?? new Set(); + for (const s of symbols) prev.add(s); + found.set(specifier, prev); + }; + // `import type { … } from` / `import type X from` — erased, skipped whole. + const named = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g; + let m; + while ((m = named.exec(source))) { + if (m[1]) continue; + const symbols = m[2] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + // `type Foo` inside a value import list is erased too. + .filter((s) => !/^type\s/.test(s)) + // `foo as bar` — the VENDOR side is the name before `as`. + .map((s) => s.split(/\s+as\s+/)[0].trim()) + .filter(Boolean); + add(m[3], symbols); + } + const defaultOrNamespace = + /import\s+(type\s+)?(?:\*\s*as\s+[A-Za-z_$][\w$]*|[A-Za-z_$][\w$]*)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g; + while ((m = defaultOrNamespace.exec(source))) { + if (m[1]) continue; + add(m[2], []); + } + const bare = /import\s*['"]([^'"]+)['"]/g; + while ((m = bare.exec(source))) add(m[1], []); + return [...found].map(([specifier, symbols]) => ({ specifier, symbols: [...symbols].sort() })); +} + +/** Every `.ts`/`.mts`/`.js`/`.mjs` under `dir/src` that is not a test file. */ +function shippedSourceFiles(dir) { + const out = []; + const src = join(dir, 'src'); + if (!existsSync(src)) return out; + const walk = (d) => { + for (const entry of readdirSyncSafe(d)) { + const full = join(d, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '__fixtures__') continue; + walk(full); + } else if (/\.(m?ts|m?js)$/.test(entry.name) && !/\.(test|spec)\.(m?ts|m?js)$/.test(entry.name)) { + out.push(full); + } + } + }; + walk(src); + return out; +} + +function readdirSyncSafe(d) { + try { + return readdirSync(d, { withFileTypes: true }); + } catch { + return []; + } +} + +/** + * Collect the governed vendor import edges of every PUBLISHABLE workspace + * package, plus the ranges those packages declare for them. + * + * @returns {{ edges: Map>>, declarations: Map> }} + * `edges`: vendor package name -> specifier -> symbols. + * `declarations`: vendor package name -> the publishable manifests declaring it. + */ +export function collectVendorEdges(root = repoRoot) { + const edges = new Map(); + const declarations = new Map(); + for (const { dir, manifest } of workspacePackages(root)) { + if (manifest.private === true) continue; + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if (!isGovernedVendor(name)) continue; + if (typeof range !== 'string' || range.startsWith('workspace:')) continue; + if (!declarations.has(name)) declarations.set(name, []); + declarations.get(name).push({ pkg: manifest.name ?? dir, dir, field, range }); + } + } + for (const file of shippedSourceFiles(join(root, dir))) { + const source = readFileSync(file, 'utf8'); + for (const { specifier, symbols } of parseStaticValueImports(source)) { + const name = packageNameOf(specifier); + if (!name || !isGovernedVendor(name)) continue; + if (!edges.has(name)) edges.set(name, new Map()); + const bySpecifier = edges.get(name); + if (!bySpecifier.has(specifier)) bySpecifier.set(specifier, new Set()); + for (const s of symbols) bySpecifier.get(specifier).add(s); + } + } + } + return { edges, declarations }; +} + +/** + * The named exports a specifier really yields, imported from `fromDir`. + * Returns null when the specifier cannot be loaded at all — the caller + * distinguishes "not installed" from "installed but missing a symbol". + */ +/** + * ⚠️ The probe file needs a UNIQUE name per call. ESM caches by resolved URL, + * so reusing one path makes the second `import()` return the FIRST probe's + * module — every specifier after the first is then measured against the wrong + * namespace, which reads as a missing export on a package that has it. + */ +let probeSeq = 0; +async function namespaceKeys(specifier, fromDir) { + const probe = join(fromDir, `.vendor-export-probe-${process.pid}-${probeSeq++}.mjs`); + try { + writeFileSync(probe, `export const ns = await import(${JSON.stringify(specifier)});\n`); + const mod = await import(pathToFileURL(probe).href); + return Object.keys(mod.ns); + } catch { + return null; + } finally { + try { + rmSync(probe, { force: true }); + } catch { + /* the probe is scratch; a failed unlink is not this gate's finding */ + } + } +} + +/** The version of `name` installed in this workspace, or null. */ +function installedVersion(name, fromDir) { + let dir = fromDir; + for (;;) { + const candidate = join(dir, 'node_modules', name, 'package.json'); + if (existsSync(candidate)) { + try { + return JSON.parse(readFileSync(candidate, 'utf8')).version ?? null; + } catch { + return null; + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// ── the offline verdict ──────────────────────────────────────────────────── + +async function runOffline(root = repoRoot) { + const findings = []; + const { edges, declarations } = collectVendorEdges(root); + const checked = []; + + for (const [vendor, bySpecifier] of [...edges].sort()) { + const declared = declarations.get(vendor) ?? []; + if (declared.length === 0) { + findings.push( + `${vendor}: publishable source statically imports runtime values from it, but NO publishable ` + + `manifest declares it. A consumer's install has no reason to contain it at all.`, + ); + continue; + } + const ranges = [...new Set(declared.map((d) => d.range))]; + const inexact = declared.filter((d) => semver.valid(d.range) === null); + for (const d of inexact) { + findings.push( + `${d.pkg} declares "${vendor}": "${d.range}" — a governed vendor range must be an EXACT ` + + `version. A range cannot state which export surface we compiled against, and this family ` + + `removes public exports in patch releases, so semver does not protect it either.`, + ); + } + if (ranges.length > 1) { + findings.push( + `${vendor} is declared with ${ranges.length} different ranges (${ranges.join(', ')}). ` + + `The family moves as one line; a consumer resolving two of them gets a mix nothing tested.`, + ); + } + if (inexact.length > 0) continue; + + const pinned = ranges[0]; + const fromDir = join(root, declared[0].dir); + const installed = installedVersion(vendor, fromDir); + if (installed === null) { + console.error( + `PREREQUISITE NOT MET: ${vendor} is declared by ${declared[0].pkg} but is not installed.\n` + + ` Run \`pnpm install\` — this gate imports the vendor to read its real export surface.`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + if (installed !== pinned) { + findings.push( + `${vendor}: declared "${pinned}" but ${installed} is installed here. The version this repo ` + + `imports and the version a consumer resolves must be the same string, or CI certifies a ` + + `surface the artifact never runs on.`, + ); + continue; + } + + for (const [specifier, symbols] of [...bySpecifier].sort()) { + const keys = await namespaceKeys(specifier, fromDir); + if (keys === null) { + findings.push(`${specifier}: cannot be imported from ${declared[0].pkg} at ${vendor}@${installed}.`); + continue; + } + const missing = [...symbols].filter((s) => !keys.includes(s)).sort(); + if (missing.length > 0) { + findings.push( + `${specifier} at ${vendor}@${installed} does not export ${missing.join(', ')} — ` + + `imported statically by ${declared[0].pkg}. A static ESM named import of a missing ` + + `export is a link-time SyntaxError: the package does not load at all.`, + ); + } + checked.push(`${specifier} @ ${vendor}@${installed} (${symbols.size} symbol(s): ${[...symbols].sort().join(', ')})`); + } + } + return { findings, checked }; +} + +// ── the network leg ──────────────────────────────────────────────────────── + +function registryVersions(name) { + const raw = execFileSync('npm', ['view', name, 'versions', '--json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : [parsed]; +} + +async function runResolve(root = repoRoot) { + const findings = []; + const checked = []; + const { edges, declarations } = collectVendorEdges(root); + const scratch = mkdtempSync(join(tmpdir(), 'os-vendor-export-')); + try { + for (const [vendor, bySpecifier] of [...edges].sort()) { + const declared = declarations.get(vendor) ?? []; + if (declared.length === 0) continue; + for (const range of [...new Set(declared.map((d) => d.range))]) { + let all; + try { + all = registryVersions(vendor); + } catch (e) { + console.error( + `PREREQUISITE NOT MET: could not read ${vendor} from the npm registry (${e?.message ?? e}).\n` + + ` --resolve measures what a consumer resolves; an unreachable registry is NOT a pass.`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + const satisfying = all.filter((v) => semver.satisfies(v, range, { includePrerelease: false })); + if (satisfying.length === 0) { + findings.push(`${vendor}: the declared range "${range}" matches no published version.`); + continue; + } + for (const version of satisfying) { + const dir = join(scratch, `${vendor.replace(/[@/]/g, '_')}-${version}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + `${JSON.stringify({ name: 'vendor-export-probe', private: true, version: '0.0.0', type: 'module' }, null, 2)}\n`, + ); + try { + execFileSync('npm', ['install', `${vendor}@${version}`, '--no-package-lock', '--no-audit', '--no-fund'], { + cwd: dir, + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch (e) { + console.error( + `PREREQUISITE NOT MET: could not install ${vendor}@${version} (${e?.message ?? e}).`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + for (const [specifier, symbols] of [...bySpecifier].sort()) { + const keys = await namespaceKeys(specifier, dir); + if (keys === null) { + findings.push( + `${specifier} cannot be imported at ${vendor}@${version}, which the declared range ` + + `"${range}" admits — a consumer resolving it gets a package that does not load.`, + ); + continue; + } + const missing = [...symbols].filter((s) => !keys.includes(s)).sort(); + if (missing.length > 0) { + findings.push( + `${specifier} does not export ${missing.join(', ')} at ${vendor}@${version}, which ` + + `the declared range "${range}" admits. Our lockfile hides this; a consumer's ` + + `install does not.`, + ); + } + checked.push( + `${specifier} @ ${vendor}@${version} (via "${range}", ${symbols.size} symbol(s))`, + ); + } + } + } + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + return { findings, checked }; +} + +// ── self-test ────────────────────────────────────────────────────────────── + +function selfTest() { + const failures = []; + const eq = (name, actual, expected) => { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) failures.push(`${name}: expected ${e}, got ${a}`); + }; + + // ── parseStaticValueImports ─────────────────────────────────────────────── + const parsed = parseStaticValueImports( + [ + "import { createLocalAccountIssuer, createOAuthAccountIssuer } from '@better-auth/core/db';", + "import type { Auth } from 'better-auth';", + "import { type WhereOperator, createAdapterFactory } from 'better-auth/adapters';", + "import { socialProviders as factories } from '@better-auth/core/social-providers';", + "const { x } = await import('@better-auth/core/error');", + ].join('\n'), + ); + const bySpec = Object.fromEntries(parsed.map((p) => [p.specifier, p.symbols])); + eq('named value import', bySpec['@better-auth/core/db'], [ + 'createLocalAccountIssuer', + 'createOAuthAccountIssuer', + ]); + eq('import type is erased', bySpec['better-auth'], undefined); + eq('inline type specifier is erased', bySpec['better-auth/adapters'], ['createAdapterFactory']); + eq('renamed import keeps the VENDOR name', bySpec['@better-auth/core/social-providers'], [ + 'socialProviders', + ]); + eq('dynamic import is not static', bySpec['@better-auth/core/error'], undefined); + + // ── packageNameOf ───────────────────────────────────────────────────────── + eq('scoped subpath', packageNameOf('@better-auth/core/db'), '@better-auth/core'); + eq('unscoped subpath', packageNameOf('better-auth/adapters/memory'), 'better-auth'); + eq('relative is not a package', packageNameOf('./local'), null); + + // ── the ledger ──────────────────────────────────────────────────────────── + eq('better-auth is governed', isGovernedVendor('better-auth'), true); + eq('@better-auth/* is governed', isGovernedVendor('@better-auth/kysely-adapter'), true); + eq('an ungoverned vendor stays ungoverned', isGovernedVendor('zod'), false); + if (GOVERNED_VENDORS.length < 1) failures.push('GOVERNED_VENDORS is empty — the gate checks nothing.'); + for (const v of GOVERNED_VENDORS) { + if (!v.evidence || v.evidence.length < 40) { + failures.push(`GOVERNED_VENDORS entry "${v.label}" carries no evidence for why it is governed.`); + } + } + + // ── the exactness rule, in BOTH directions ──────────────────────────────── + // A gate that has only ever been seen to pass is not a gate. These pin the + // predicate the offline verdict is built on. + eq('an exact version is exact', semver.valid('1.7.2') !== null, true); + eq('a caret range is NOT exact', semver.valid('^1.7.2') !== null, false); + eq('a tilde range is NOT exact', semver.valid('~1.7.2') !== null, false); + eq('a wildcard is NOT exact', semver.valid('*') !== null, false); + // The live specimen: `^1.7.2` admits the version that dropped the export. + eq('^1.7.2 admits 1.7.3', semver.satisfies('1.7.3', '^1.7.2'), true); + eq('1.7.2 admits only itself', semver.satisfies('1.7.3', '1.7.2'), false); + + // ── the repo's own state, read through the same collector ───────────────── + const { edges, declarations } = collectVendorEdges(); + const core = edges.get('@better-auth/core'); + if (!core || !core.has('@better-auth/core/db')) { + failures.push( + 'the collector no longer sees the @better-auth/core/db edge in publishable source — ' + + 'either the import moved (update this case) or the scan stopped reaching plugin-auth.', + ); + } else { + const symbols = [...core.get('@better-auth/core/db')].sort(); + if (!symbols.includes('createLocalAccountIssuer')) { + failures.push( + 'the @better-auth/core/db edge no longer names createLocalAccountIssuer — if the durable ' + + 'fix landed, retire this case with it.', + ); + } + } + if (!declarations.has('@better-auth/core')) { + failures.push('no publishable manifest declares @better-auth/core — the collector lost the declaration side.'); + } + + if (failures.length > 0) { + console.error('check-vendor-export-contract --self-test FAILED:'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log(`check-vendor-export-contract --self-test OK (${GOVERNED_VENDORS.length} governed vendor family)`); +} + +// ── entry ────────────────────────────────────────────────────────────────── + +// Only when RUN, never when imported: the self-test and the checklist tests +// import the collector, and a module that runs its verdict on import would +// make every importer inherit this gate's exit code. +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +const args = isMain ? process.argv.slice(2) : ['--noop']; +if (!isMain) { + /* imported for its exports */ +} else if (args.includes('--self-test')) { + selfTest(); +} else { + const wantResolve = args.includes('--resolve'); + const { findings, checked } = wantResolve ? await runResolve() : await runOffline(); + const leg = wantResolve ? 'registry resolution' : 'installed workspace'; + if (findings.length > 0) { + console.error(`VERDICT: FAIL — vendor export contract (${leg})`); + for (const f of findings) console.error(` - ${f}`); + console.error( + '\nRemedy: pin the declaring manifest AND pnpm-workspace.yaml `overrides` to the exact\n' + + 'version whose export surface this repo compiles against, in one commit, for the whole\n' + + 'family. ⛔ Never widen the range to make this pass — a wider range is the defect.', + ); + process.exit(1); + } + console.log(`VERDICT: PASS — vendor export contract (${leg}), ${checked.length} edge(s) verified`); + for (const c of checked) console.log(` ✓ ${c}`); +} From 9d0fda8e8b95fcf3ddbe5cdcd29b760e242a3da8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang Date: Mon, 7 Sep 2026 21:51:30 +0800 Subject: [PATCH 3/4] chore: wire the vendor export contract into CI and add the changeset (#16186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline leg joins the lint farm (no `paths:` filter — a manifest edit, a lockfile refresh and a new vendor import can each move it). The network leg joins validate-deps.yml, which already installs from the registry and runs daily, so "a vendor published something today" becomes a red here instead of in a customer's install. Co-Authored-By: Claude Opus 5 --- .changeset/better-auth-exact-family-pin.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/better-auth-exact-family-pin.md diff --git a/.changeset/better-auth-exact-family-pin.md b/.changeset/better-auth-exact-family-pin.md new file mode 100644 index 0000000000..ae4ac11d1b --- /dev/null +++ b/.changeset/better-auth-exact-family-pin.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-auth": patch +--- + +`@objectstack/plugin-auth` pins the `better-auth` family to an exact `1.7.2`, so a fresh install of a published `@objectstack/*` release loads the auth plugin again — and with it creates the system tables and seeds the admin. + +Published 17.1.0, 17.2.0 and 17.3.0 declared `"@better-auth/core": "^1.7.2"` and imported `createLocalAccountIssuer` / `createOAuthAccountIssuer` from `@better-auth/core/db`. `@better-auth/core@1.7.3` — a **patch** — deleted both names, and the `account.issuer` column behind them, because upstream rolled the issuer-scoped account identity back to opt-in (better-auth/better-auth#10909). A static ESM named import of a missing export is a link-time `SyntaxError`, so the plugin could not load at all. Every symptom followed from that one failure and every one of them was quiet: the scaffolded project's CLI printed the `SyntaxError` as a scrollable oclif warning and carried on, the server printed `✓ Server is ready` on the broken boot, `sys_user` / `sys_organization` / `sys_permission_set` / `sys_position` were never created, the seeded admin sign-in never answered, and the Console's sign-in form answered `Auth request failed with status 404`. + +**This is a stopgap, deliberately, and it is labelled as one.** Upstream removed the export on purpose; adopting 1.7.3 means dropping `sys_account.issuer` — a required column with a unique `(issuer, accountId)` index — from the platform object, retiring the boot-time backfill that stamps it, and migrating every existing deployment. That is its own change with its own decision to make; this one restores a working install today. + +All five members `plugin-auth` declares move together (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`), because they are only correct as one line: `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions. `better-auth@1.7.2` declares its own siblings exactly, so pinning those five resolves all twelve family members to 1.7.2 — measured on a fresh `npm install` with no lockfile. + +The workspace `overrides` move to the same exact target in step, so the version this repository tests is the version a consumer resolves. In-repo resolutions are unchanged: the lockfile already held 1.7.2 for all eleven overridden members. From b09fe9d8fafb7d4ac222c1619528518a8877a2dd Mon Sep 17 00:00:00 2001 From: Jack Zhuang Date: Mon, 7 Sep 2026 22:14:56 +0800 Subject: [PATCH 4/4] fix(scripts): route the new gate's entry guard through isEntrypoint (#16186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:entry-guard` caught a hand-typed `process.argv[1]` comparison: it goes inert through a symlinked checkout, silently, at exit 0 — which for a gate means a green that never ran. Co-Authored-By: Claude Opus 5 --- scripts/check-vendor-export-contract.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/check-vendor-export-contract.mjs b/scripts/check-vendor-export-contract.mjs index 3553477b38..3592757cd0 100644 --- a/scripts/check-vendor-export-contract.mjs +++ b/scripts/check-vendor-export-contract.mjs @@ -106,6 +106,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { workspacePackages } from './workspace-enumerator.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; import { EXIT_PREREQUISITE_NOT_MET, requireDefaultExport } from './import-prerequisite.mjs'; const semver = await requireDefaultExport('semver', () => import('semver'), import.meta.url); @@ -548,11 +549,12 @@ function selfTest() { // ── entry ────────────────────────────────────────────────────────────────── -// Only when RUN, never when imported: the self-test and the checklist tests -// import the collector, and a module that runs its verdict on import would -// make every importer inherit this gate's exit code. -const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); -const args = isMain ? process.argv.slice(2) : ['--noop']; +// Only when RUN, never when imported: a caller that imports the collector must +// not inherit this gate's exit code. `isEntrypoint` is the one predicate in +// scripts/ that survives a symlinked checkout — a hand-typed process.argv[1] +// comparison goes inert there, silently, at exit 0. +const isMain = isEntrypoint(import.meta.url); +const args = isMain ? process.argv.slice(2) : []; if (!isMain) { /* imported for its exports */ } else if (args.includes('--self-test')) {