Skip to content

fix(plugin-auth): pin the better-auth family to an exact 1.7.2, and gate the declared range against our own import surface - #16634

Draft
hotlong wants to merge 4 commits into
mainfrom
claude/issue-16186-better-auth-exact-pin
Draft

fix(plugin-auth): pin the better-auth family to an exact 1.7.2, and gate the declared range against our own import surface#16634
hotlong wants to merge 4 commits into
mainfrom
claude/issue-16186-better-auth-exact-pin

Conversation

@hotlong

@hotlong hotlong commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #16186

@objectstack/plugin-auth pins the better-auth family to an exact 1.7.2, and a new gate makes the class stay closed.

The defect, restated from the artifact's side

plugin-auth's shipped dist/index.mjs line 825 carries a static named import:

import { createLocalAccountIssuer, createOAuthAccountIssuer } from "@better-auth/core/db";

and the manifest declared "@better-auth/core": "^1.7.2". @better-auth/core@1.7.3 — a patch — deleted both names. A static ESM named import of a missing export is a link-time SyntaxError, so the plugin did not load at all. Everything reported on the card follows from that one failure, and every symptom was quiet: the scaffolded project's CLI printed the SyntaxError as a scrollable oclif warning and carried on, the server printed the ready banner on the broken boot, the four system objects were never created, the seeded admin sign-in never answered, the Console's sign-in form answered Auth request failed with status 404.

Measured here, not restated (all commands below were run):

  • a fresh npm install --no-package-lock of main's five declared ranges resolves the whole family to 1.7.3better-auth, core, oauth-provider, scim, sso, kysely-adapter, telemetry
  • loading plugin-auth's own built dist/index.mjs against @better-auth/core@1.7.3 reproduces the reported error verbatim, and against 1.7.2 it loads:
LEG A (core 1.7.3) EXIT=1
  dist/index.mjs:825
  import { createLocalAccountIssuer, createOAuthAccountIssuer } from "@better-auth/core/db";
  SyntaxError: The requested module '@better-auth/core/db' does not provide an export named 'createLocalAccountIssuer'
LEG B (core 1.7.2) EXIT=0
  LOADED OK

Which of the two candidates this is, plainly

This is the stopgap — the exact pin — and it is labelled as one. It is not the durable fix. The durable fix is #16629, filed with the measurements below.

Reading the installed 1.7.3 first, as asked: createLocalAccountIssuer was not renamed and has no replacement. Upstream removed the issuer-scoped account identity outright (better-auth/better-auth#10909). Diffing the two published src trees:

  • db/schema/account.tsissuer is gone from accountSchema; AccountKey reverts to Pick(BaseAccount, "providerId" | "accountId"); both helper functions are deleted
  • db/get-tables.ts — the account.issuer column and the unique (issuer, accountId) index are gone
  • oauth2/oauth-provider.ts — the accountIssuer option is gone; accountIssuer occurs zero times in the whole 1.7.3 package

So adopting 1.7.3 means dropping sys_account.issuer — a required platform-object column under a unique index — retiring backfill-account-issuer.ts and its 44-reference test, retiring account-issuer-parity.test.ts, and migrating every existing deployment. That is its own change with its own decision; #16629 states the three options and the costs. Shipping it inside a p0 hotfix would be the wrong trade.

All five declared members move, not one

@better-auth/core@1.7.2 and @better-auth/kysely-adapter@1.7.3 are mutually incompatible in both directions (1.7.2 has createLocalAccountIssuer and lacks checksSchema, which 1.7.3's db/internal added; 1.7.3 is the reverse). better-auth@1.7.2 declares its own siblings exactly, so pinning the five that plugin-auth declares drags all twelve — measured on a clean npm install --no-package-lock, twelve members at 1.7.2, one copy each.

The pnpm-workspace.yaml override targets move to the same exact 1.7.2 in step, so the version this repository tests is the version a consumer resolves. Selectors keep their <2.0.0 major boundary, so a future lift stays a target-only edit. In-repo resolutions are unchanged: the lockfile already held 1.7.2 for all eleven overridden members, and pnpm install --lockfile-only moved only the echoed specifier strings.

This does not change the resolution #16530 is about — that card is already measured against oauth-provider 1.7.2, the version this pin holds.

The regression control — scripts/check-vendor-export-contract.mjs

A version pin alone leaves this repository exactly as blind as it was: pnpm-lock.yaml held 1.7.2, so every job here imported the version that still had the symbol, while the artifact it produced was broken on install. The lockfile protected the producer from the defect it was shipping.

scripts/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 perfectly, all the way onto the broken version.

The new gate takes the question the other way round. For every governed vendor a publishable package statically imports runtime values from, it asserts:

  1. some publishable manifest declares it;
  2. every declaring range is an exact version, and they agree;
  3. the version installed here is that version; and
  4. importing each specifier really yields every named symbol our shipped source takes from it.

(2) and (3) are what make (4) a statement about consumers rather than about our lockfile: an exact range resolves to one version everywhere, so the surface just verified is the surface a fresh install gets.

--resolve measures the same property instead of proving it, and is the literal reading of the ask: it enumerates every registry version the declared range admits — no lockfile involved — installs each outside this workspace, and fails if any is missing a symbol we import.

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). --resolve joins validate-deps.yml, which already installs from the registry and runs daily, so "a vendor published something today" becomes a red here rather than in a customer's install. An unreachable registry exits 3 (PREREQUISITE NOT MET), never a green.

Scope is a ledger of vendor names carrying their evidence, not a blanket "pin everything": a repo-wide exactness rule is a different decision with a real cost. Type-only imports are out of scope (erased before runtime); test files are out of scope (they fail in pnpm test, loudly, on the same install).

The control was watched failing — twice, for two different reasons

Ablation: mutate packages/plugins/plugin-auth/package.json back to main's ^1.7.2 (mutation confirmed on disk before reading anything: exact=5 caret=0 before, exact=0 caret=5 after; blob 7ced877e… to 8ab88d1d…), run both legs, restore against HEAD.

OFFLINE EXIT=1
  - @objectstack/plugin-auth declares "@better-auth/core": "^1.7.2" — a governed vendor
    range must be an EXACT version. ...
  - @objectstack/plugin-auth declares "better-auth": "^1.7.2" — ...

RESOLVE EXIT=1
  - @better-auth/core/db does not export createLocalAccountIssuer,
    createOAuthAccountIssuer at @better-auth/core@1.7.3, which the declared range
    "^1.7.2" admits. Our lockfile hides this; a consumer's install does not.

Restore leg proved, not assumed: restored blob 7ced877e… equals the HEAD blob, git diff HEAD empty, git status --porcelain clean. No build/dist step is involved on either leg — the gate reads manifests, source text and installed packages — so there is no stale-artifact direction to rule out.

Acceptance, each item watched running

1. A fresh install of the published-shape artifact seeds. bash scripts/publish-smoke.sh (pack mode) passed end to end: every publishable package packed, a project scaffolded outside this workspace, installed from the tarballs, built, and booted. The scaffolded project carries no better-auth override — only the peerDependencyRules.allowedVersions report suppression, which moves no resolution — so its family came from plugin-auth's own declared ranges, and all seven checked members resolved 1.7.2.

Then, read directly rather than inferred, against that same packed scaffold booted with --fresh --seed-admin:

seeded admin sign-in: OK (token acquired)
GET /api/v1/data/sys_user            -> 200  {"object":"sys_user","records":[{... "email":"admin@objectos.ai" ...
GET /api/v1/data/sys_organization    -> 200  {"object":"sys_organization","records":[{... "name":"Default Organization" ...
GET /api/v1/data/sys_permission_set  -> 200  {"object":"sys_permission_set","records":[{... "label":"Ad ...
GET /api/v1/data/sys_position        -> 200  {"object":"sys_position","records":[{... "label":"Pl ...

zero no such table, zero SyntaxError, zero degraded capabilities, zero AuthPlugin failed lines in the server log.

2. The scaffolded project's CLI prints no SyntaxError warning (carried over from #16373). Run in the packed scaffold: objectstack --version, objectstack validate, objectstack build — all exit 0, no SyntaxError, no does not provide an export, no Warning: line at all.

3. The regression control fails when pointed at the broken resolution — shown above, both legs.

Is main fixed, not merely the pinned build

Yes — and the answer is about the declared ranges, which is where the defect lived. Before this branch, main's five declared ranges resolved to 1.7.3 on a fresh no-lockfile install (measured today); after it they resolve to 1.7.2, and the packed scaffold above boots and seeds. A release cut from this branch does not ship the defect. A release cut from main before it does.

One thing this PR deliberately does not do: it makes no claim about already-published 17.1.0 / 17.2.0 / 17.3.0, whose manifests are immutable on npm. Those remain broken for anyone installing them; the consumer-side workaround on the card (a family-wide pnpm.overrides block) is still the only remedy for an existing install until a release carrying this ships.

Verification

Union run at b09fe9d8f, on a clean tree (git status --porcelain empty).

  • pnpm --filter '@objectstack/plugin-auth^...' build — exit 0
  • pnpm --filter @objectstack/plugin-auth build — exit 0
  • pnpm --filter @objectstack/plugin-auth test102 files, 2158 tests, all passed
  • pnpm --filter @objectstack/plugin-auth typecheck — exit 0 (tsc --noEmit, the examples project, and check:test-typecheck: 10 files / 94 errors / 23 pinned signatures held, unchanged)
  • pnpm build --concurrency=2 — 73/73 tasks successful
  • bash scripts/publish-smoke.sh (SMOKE_MODE=pack) — exit 0
  • pnpm lint — the full repo scan, not a narrowing: 6295 files linted, 0 errors, 0 warnings
  • 25 gates derived from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack and run at that sha, all exit 0: nul-bytes, agent-test-spelling, entry-guard, parse-guard, watch-hint-literal, doc-authoring, cli-command-ids, cross-package-test-inputs, pnpm-filter-targets, pnpm-acquisition, override-consistency, vendor-export-contract, changeset-gate-self-tests, objectui-changeset, required-contexts, workflow-status-functions, single-claim-paths, pm-dispatch-gates, published-files, manifest-repository-directory, workspace-manifest-cycles, turbo-task-graph, type-check-coverage, type-check-debt, scripts-symbol-anchors
  • pnpm check:vendor-export-contract --resolve — exit 0 against the pinned ranges

check:entry-guard found a real defect in the first draft of the new gate (a hand-typed process.argv[1] comparison, which goes inert through a symlinked checkout at exit 0) and it is fixed in b09fe9d8f.

One gate NOT MEASURED here, and it is not a green: pnpm check:bash32-floor exits 1 on this macOS host, in its own --self-test, because the host's bash IS 3.2 and has no mapfile, so the harness cannot prove its simulated-3.2 shell removed a builtin the real shell never had. It fails identically (8 of 153 cases) on the untouched shared main checkout, and this diff contains no shell file at all. CI's ubuntu runner is where that gate is measured.

Verification-lock disclosure: every heavy command was routed through scripts/pm/os-verify-lock.sh, which reported UNLOCKED (declared) on every run — flock is util-linux and this macOS host does not ship it, so the shared lock was never taken and nothing was serialized. Wall-clock numbers from these runs are shared-box readings, not quiet-machine claims.

Out of scope, filed rather than fixed here

🤖 Generated with Claude Code


Generated by Claude Code

hotlong and others added 4 commits September 7, 2026 21:37
`@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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…16186)

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 <noreply@anthropic.com>
…16186)

`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 <noreply@anthropic.com>
@github-actions github-actions Bot added size/l ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tooling labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/package.json) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8341ed21cededfc1d4f953c54b71e76c26628ee9packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tooling

Projects

None yet

1 participant