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. 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/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' diff --git a/scripts/check-vendor-export-contract.mjs b/scripts/check-vendor-export-contract.mjs new file mode 100644 index 0000000000..3592757cd0 --- /dev/null +++ b/scripts/check-vendor-export-contract.mjs @@ -0,0 +1,578 @@ +#!/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 { 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); + +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: 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')) { + 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}`); +}