Skip to content

chore: upgrade Better Auth 1.7, unify deploys, guard db:push - #2181

Merged
koistya merged 1 commit into
mainfrom
chore/better-auth-1.7-release-guards
Aug 23, 2026
Merged

chore: upgrade Better Auth 1.7, unify deploys, guard db:push#2181
koistya merged 1 commit into
mainfrom
chore/better-auth-1.7-release-guards

Conversation

@koistya

@koistya koistya commented Aug 23, 2026

Copy link
Copy Markdown
Member

Why

Better Auth 1.7 changes how an external identity is keyed, which is a required schema migration rather than a version bump. Working through it surfaced three rules this repo already documented but did not enforce — how a release is deployed, where db:push may point, and which database role the workers run as — so each becomes a control the code applies.

Better Auth 1.7

An identity is now keyed on (issuer, accountId) instead of (providerId, accountId), and issuer is required. identity gains the column and swaps its composite unique. The old constraint is not kept: issuer is a function of providerId, so (issuer, accountId) implies it.

issuer: text().notNull(),
// ...
unique("identity_issuer_account_unique").on(table.issuer, table.accountId),

db/migrations/0000_init.sql is regenerated in place per the squashed-init convention — run bun db:push after pulling.

getAuthTables() also began reporting the indexes, literal defaults and FK cascades a plugin declares. db/scripts/generate-auth-schema.ts emitted none of them, and /validate-auth-schema told reviewers to ignore exactly the metadata that changed, so both now carry it. Everything else — Stripe's organization.enabled, the account selectors, getIpgetIP — was already satisfied or unused.

bun db:generate works again

The snapshot's top-level version was hand-edited to "1" at some point, and current drizzle-kit rejects that with data is malformed — so db:generate silently did nothing on any schema change. It moves to "7", the format drizzle-kit writes today, and now reports No schema changes against this branch's schema. The journal entry's own version is never read, so it stays as is.

One deploy path

bun deploy:{staging,production} builds, checks each dist/ exists, then deploys apiappweb. deploy.yml calls the same script with --skip-build, replacing three inline wrangler deploy lines, so a release from a laptop and one from Actions cannot drift in order or in environment selection. The script owns the production → empty---env mapping and rejects any other name, so a value lost in transit fails the run instead of deploying production.

No --env-file flags: Wrangler already loads .env and .env.local for every command and merges them under process.env, so listing them would only suppress .env.staging.local on a staging deploy without changing which credentials win.

db:push refuses a non-local database

push infers a schema change and applies it in place — against real rows that is a migration nobody reviewed, and it drops a column, and its data, to make the shapes agree. The rule lived in docs/security/checklist.md; db/scripts/guard-push.ts now enforces it, with ALLOW_REMOTE_DB_PUSH=1 as the deliberate way past. The classifier is tested in both directions, because a local database wrongly refused teaches people to reach for the override, and an override reached for by habit is no longer a control.

Least-privilege database role

db/scripts/grant-app-role.sql provisions the role Hyperdrive should hold: DML only, no DDL, CONNECT revoked from PUBLIC so a leaked staging credential cannot open production on the same Neon project. It refuses to run unless the caller owns the database and schema public, because Postgres answers an unauthorised REVOKE with a warning and exit 0 — without the check, the wrong runner leaves a role that looks provisioned with none of the boundary around it. It also refuses a role name that already carries privileges of its own, since it only ever grants.

Tests run on Bun, coverage works

The four test scripts become bun --bun vitest. Bun 1.4 runs Vitest as a host runtime; only the runtime changes — same Vitest, same config, same 92 tests, consistently faster across every documented invocation (--run, --project, filename filters, watch).

Node Bun
run 1 4.79s 3.81s
run 2 5.99s 4.32s

Vitest itself stays. Bun's own runner has no environment: happy-dom, no projects, and no vi.* namespace — bun test apps/app/lib/theme.test.tsx fails with document is not defined, and the suite uses vi.mock, vi.hoisted and vi.stubGlobal. Bun's --pool=threads was also measured and is 2× slower here, so the default forks stays.

Coverage is set up per the Vitest guide, replacing an apps/app script that could not run — no provider was installed, and it asked for watch mode. coverage.include is what makes an unimported module report 0% rather than vanish from the total; only generated code, build output and test scaffolding are excluded. It runs on Node, the one place the Bun runtime does not hold up: merging v8 coverage for this suite overflows the stack inside @bcoe/v8-coverage, reproducibly.

Updating a project built on this kit

Forks track this repo as the seed remote and sync with the merge-seed skill – /merge-seed in Claude Code, or merge-seed in Codex, both reading .agents/skills/merge-seed/SKILL.md. It branches, merges seed/main with zdiff3 so the merge base stays visible, and resolves under one rule: upstream owns mechanism, your project owns identity and scope.

Two things here need a decision the skill deliberately will not make for you.

It will stop on the migration. 0000_init.sql is rewritten in place under this repo's squashed-init convention, and the skill treats a migration that may already have run as immutable, so it escalates instead of resolving. That is the right outcome: keep your own migration history, take only the db/schema/ change, and let bun db:generate write issuer as a new migration for your project. That command works again as of this PR.

A live database needs a backfill. issuer is NOT NULL with no default, so the generated migration will fail on a non-empty identity table – Drizzle emits a single ADD COLUMN ... NOT NULL. Split it by hand: add the column nullable, populate it, then add the constraint.

Backfill outline

Issuer values come from Better Auth's 1.7 upgrade guide: local:credential for password accounts, the provider's published issuer for OIDC providers, and local:oauth:<providerId> for an OAuth provider that publishes none.

ALTER TABLE "identity" ADD COLUMN "issuer" text;

UPDATE "identity" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential';
UPDATE "identity" SET "issuer" = 'https://accounts.google.com' WHERE "provider_id" = 'google';
-- ...one statement per provider you have configured

-- Must return no rows before the constraint can be added.
SELECT "issuer", "account_id" FROM "identity"
GROUP BY 1, 2 HAVING count(*) > 1;

ALTER TABLE "identity" ALTER COLUMN "issuer" SET NOT NULL;
ALTER TABLE "identity" DROP CONSTRAINT "identity_provider_account_unique";
ALTER TABLE "identity" ADD CONSTRAINT "identity_issuer_account_unique" UNIQUE ("issuer", "account_id");

Credential accounts key on the linked user's id, which is already what account_id holds – no change there. Stop and reconcile by hand if the collision query returns anything; never merge users by matching email.

Everything else in this PR is ordinary mechanism the skill adopts on its own. Verify with its checklist afterwards:

bun install --frozen-lockfile && bun typecheck && bun lint && bun run test -- --run && bun run build

Also

@typescript-eslint/no-unused-vars gains ignoreRestSiblings, so const { password, ...rest } = user stops being a lint error in a kit that ships a password column.

Verification

Check Result
bun run test --run 92 passed, up from 80
bun run coverage clean run, 21.27% statements
bun typecheck pass
bun run build, bun run docs:build pass
bun db:generate No schema changes
wrangler deploy --dry-run (all three workers, --env staging) pass
bun lint on changed files pass

The Better Auth flows were exercised end to end against PGlite — sign-up writes issuer: "local:credential", plus email OTP, organization create, invite, and the session hook. grant-app-role.sql was verified against a real PostgreSQL 17 cluster: DML works, CREATE/TEMP/DROP/ALTER are denied, cross-database CONNECT is denied, and the wrong-runner and elevated-role names exit non-zero without creating anything.

@koistya
koistya force-pushed the chore/better-auth-1.7-release-guards branch from 964e27d to b61bf8d Compare August 23, 2026 20:25
Better Auth 1.7 keys an external identity on `(issuer, accountId)` instead
of `(providerId, accountId)`, and `issuer` is required. `identity` gains the
column and swaps its composite unique; the old constraint is now implied,
since issuer is a function of providerId. `db/migrations/0000_init.sql` is
regenerated in place per the squashed-init convention - run `bun db:push`
after pulling.

The snapshot's top-level `version` moves from 1 to 7, the format current
drizzle-kit writes. It was hand-edited to 1 at some point, and drizzle-kit
had been rejecting it with `data is malformed`, so `bun db:generate` did
nothing on any schema change. The journal entry's own `version` is not read.

`getAuthTables()` also started reporting the indexes, defaults and cascades
a plugin declares. `generate-auth-schema.ts` emitted none of them, and the
validation command told reviewers to ignore exactly the metadata that
changed, so both now carry it.

Three rules the docs stated become controls the code enforces:

- `bun deploy:{staging,production}` builds and deploys api, app and web in
  one script that `deploy.yml` also calls, so a release from a laptop and
  one from Actions cannot drift in order or environment selection. It owns
  the production-to-empty-`--env` mapping and rejects any other name.
- `db:push` refuses a non-local database. Reshaping in place is a migration
  nobody reviewed, and it drops columns to make the shapes agree.
  `ALLOW_REMOTE_DB_PUSH=1` is the deliberate way past it.
- `grant-app-role.sql` provisions the least-privilege role Hyperdrive should
  use. It asserts the runner owns the database and schema first, because
  Postgres answers an unauthorised REVOKE with a warning and exit 0.

The four `test` scripts become `bun --bun vitest`. Bun 1.4 runs Vitest as a
host runtime, and only the runtime changes - same Vitest, same config, same
92 tests, consistently faster. Vitest itself stays: Bun's own runner has no
`environment: happy-dom`, no `projects`, and no `vi.*` namespace, so the DOM
suite cannot run under it.

Coverage is wired up per the Vitest guide, replacing an `apps/app` script
that could not run: no provider was installed and it asked for watch mode.
`coverage.include` is what makes an unimported module report 0% rather than
vanish from the total. It runs on Node, the one place the Bun runtime does
not hold up - merging v8 coverage for this suite overflows its stack.

`no-unused-vars` gains `ignoreRestSiblings`, so `const { password, ...rest }`
stops being a lint error in a kit that ships a password column.
@koistya
koistya force-pushed the chore/better-auth-1.7-release-guards branch from b61bf8d to aa19a3f Compare August 23, 2026 20:47
@koistya
koistya merged commit 0aa7603 into main Aug 23, 2026
9 checks passed
@koistya
koistya deleted the chore/better-auth-1.7-release-guards branch August 23, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants