fix: upgrade Better Auth to 1.7.16 with account migration (#18906)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - The board uses Better Auth for credential accounts and sessions.
> - The server pins Better Auth 1.7.2.
> - The November security release fixes 12 issues in Better Auth.
> - The new version stops writing the old required account issuer field.
> - This change updates the packages and the database contract together.
> - New users can sign up, and existing users can still sign in.

## Linked Issues or Issue Description

**What happened?**
The server uses Better Auth 1.7.2. The [security
release](https://better-auth.com/blog/security-release-2026-11-03) fixes
12 issues in 1.7.16. Earlier update PRs #17909 and #17911 target 1.7.7,
which is below this security release.

**Expected behavior**
Use the patched stable release. Keep new and existing credential
accounts usable.

**Steps to reproduce**
Read the server dependency pin. Upgrade it without a schema change.
Better Auth then rejects the required account issuer column. See the
[upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide).

## What Changed

- Pin Better Auth to 1.7.16. The repository refresh bot owns the
committed Core lockfile.
- Make the old issuer column nullable. Keep existing issuer values.
- Replace the issuer index with a unique provider/account index.
- Generate the migration and retain the five newest schema snapshots.
- Test new signup, new-account sign-in, legacy issuer sign-in, duplicate
rejection, and separate provider identities with real PostgreSQL.
- Update the database schema test. Test the historical issuer backfill
separately. Verify upgrades over existing rows and rollback on duplicate
provider/account keys.
- Document the duplicate check and migration order.
- Add the approved two-line dependency resolution step before the backup
job’s frozen install. Leave the frozen check and backup tests intact.

## Verification

- Core head: `af70282c7e24335b8688c8f3cc88fdf65045632c`; parent
`0f1481e4462100357a7caa496b875cf0bc500d95` includes master `84fc67317`.
- Nine changed files. The only workflow edit is the approved two-line
backup dependency resolution step; no managed lockfile edits.
- Migration `0465_better_auth_account_identity.sql` is three SQL
statements; snapshot changes only `public.account`, five newest
snapshots retained.
- DB typecheck and 23 focused tests passed on the parent. Both real
PostgreSQL migration/signup suites were rerun November 11: all 4 tests
passed. Application source and tests are unchanged in this workflow-only
commit.
- The parent’s sandbox-provider lane passed all 205 file-sync tests but
reported an unhandled EPIPE during the symlink rejection case; its
aggregate verify therefore failed. No unrelated source change was made.
The new head reruns the full applicable CI.
- Reproduced the old backup install failure locally. Resolving manifests
followed by a frozen install passed locally; restored the bot-owned
committed lock afterward. Hosted backup qualification remains required.
- Current-head CI and review are running; no final-head green claim or
merge yet.

## Risks

Apply the migration before the new server starts. Check for duplicate
provider/account pairs first. Duplicate rows stop index creation. Keep
separate users separate when resolving duplicates. The migration keeps
issuer data. Do not restore its NOT NULL constraint while new rows
contain null. No deployment is included.

## Model Used

OpenAI GPT-6 Codex, with reasoning and tool use. The runtime did not
expose a more specific model ID or context window.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-11 08:26:58 -06:00
1 parent aaf1337c7b
commit 81c9143fe4
9 files changed
+252 -59

No files matched your search

@@ -46,6 +46,8 @@ jobs:
with:
node-version: '24'
cache: pnpm
- name: Resolve dependency manifests for this run
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
- run: pnpm install --frozen-lockfile --ignore-scripts
- name: Build workspace import prerequisites
run: |
+23
View File
@@ -1,5 +1,28 @@
# Database
### Better Auth 1.7.16 upgrade
Better Auth 1.7.3 and later identify accounts by `provider_id` and `account_id`.
They no longer write the `issuer` column that 1.7.0–1.7.2 required. Apply the
schema migration before starting the updated server. It makes `issuer` nullable,
keeps its existing values, removes the old issuer index, and adds a unique index
on the provider/account pair. This permits new credential signups and preserves
existing accounts.
Before the upgrade, check for duplicate provider/account pairs:
```sql
SELECT provider_id, account_id, count(*)
FROM account
GROUP BY provider_id, account_id
HAVING count(*) > 1;
```
Resolve any duplicates before migration. Keep different users separate. A duplicate
causes index creation to fail rather than leaving an ambiguous login identity.
Do not restore the issuer `NOT NULL` constraint on rollback while new rows have
null issuers. See the [Better Auth upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide).
### Durable native provider backups
`native_harness_backups` journals encrypted provider-session archives in object
@@ -1,17 +1,8 @@
/**
* The `account.issuer` backfill has to be total. The column lands NOT NULL, so
* a row the UPDATE misses aborts the whole migration and leaves an upgraded
* install with no working auth at all. This suite rewinds the migration,
* seeds the pre-upgrade rows an existing deployment would have, and re-applies
* it — a backfill narrowed to one provider fails here.
*/
import { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import postgres from "postgres";
import { afterEach, describe, expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { authAccounts } from "./schema/auth.js";
import {
getEmbeddedPostgresTestSupport,
@@ -21,10 +12,6 @@ import {
const MIGRATION_FILE = "0230_better_auth_account_issuer.sql";
const UNIQUE_INDEX = "account_issuer_account_id_uq";
// Better Auth's own issuer helpers: `createLocalAccountIssuer("credential")`
// for email/password and `createOAuthAccountIssuer(providerId)` for a social
// provider that declares no issuer of its own. Sign-in looks the credential
// account up by this exact value.
const CREDENTIAL_ISSUER = "local:credential";
const OAUTH_ISSUER_PREFIX = "local:oauth:";
@@ -32,33 +19,32 @@ const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(
new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url),
"utf8",
);
return createHash("sha256").update(content).digest("hex");
async function applySqlMigration(sql: postgres.Sql, file: string) {
const content = await fs.promises.readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8");
await sql.begin(async (transaction) => {
for (const statement of content.split("--> statement-breakpoint")) {
if (statement.trim()) await transaction.unsafe(statement);
}
});
}
describe("account issuer schema", () => {
it("declares the column Better Auth requires and the key it indexes on", () => {
it("keeps issuer nullable and identifies accounts by provider and account ID", () => {
const config = getTableConfig(authAccounts);
const issuer = config.columns.find((column) => column.name === "issuer");
expect(issuer).toBeDefined();
expect(issuer?.notNull).toBe(true);
expect(issuer?.notNull).toBe(false);
// Better Auth declares `(issuer, accountId)` unique on the account model
// and resolves accounts by that pair.
const unique = config.indexes.find((index) => index.config.name === UNIQUE_INDEX);
const unique = config.indexes.find((index) => index.config.name === "account_provider_account_id_uq");
expect(unique?.config.unique).toBe(true);
expect(unique?.config.columns.map((column) => (column as { name: string }).name)).toEqual([
"issuer",
"provider_id",
"account_id",
]);
});
});
describeEmbeddedPostgres("account issuer migration", () => {
describeEmbeddedPostgres("historical issuer migration and patched provider identity upgrade", () => {
// Reverse registration order, one at a time. The raw `postgres` client is not
// registered with the module's client registry, so the cluster teardown does
// not close it. Stopping the cluster while that client is still draining kills
@@ -75,8 +61,7 @@ describeEmbeddedPostgres("account issuer migration", () => {
const sql = postgres(database.connectionString, { max: 1 });
cleanups.push(async () => sql.end());
// Rewind to the pre-upgrade shape: no issuer column, no unique index.
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP INDEX IF EXISTS "account_provider_account_id_uq"`;
await sql`DROP INDEX IF EXISTS ${sql(UNIQUE_INDEX)}`;
await sql`ALTER TABLE "account" DROP COLUMN IF EXISTS "issuer"`;
@@ -101,7 +86,7 @@ describeEmbeddedPostgres("account issuer migration", () => {
(${googleAccountId}, 'google-subject-1', 'google', ${socialUserId}, NULL, now(), now())
`;
await applyPendingMigrations(database.connectionString);
await applySqlMigration(sql, MIGRATION_FILE);
const rows = await sql<{ id: string; issuer: string }[]>`
SELECT "id", "issuer" FROM "account" ORDER BY "id"
@@ -133,5 +118,50 @@ describeEmbeddedPostgres("account issuer migration", () => {
VALUES (${`account-${randomUUID()}`}, ${CREDENTIAL_ISSUER}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now())
`,
).rejects.toMatchObject({ code: "23505", constraint_name: UNIQUE_INDEX });
await applySqlMigration(sql, "0465_better_auth_account_identity.sql");
const preserved = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`;
expect(preserved).toEqual(rows);
const [nullable] = await sql`
SELECT "is_nullable" FROM "information_schema"."columns"
WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer'
`;
expect(nullable?.is_nullable).toBe("YES");
await expect(sql`
INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at")
VALUES (${randomUUID()}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now())
`).rejects.toMatchObject({ code: "23505", constraint_name: "account_provider_account_id_uq" });
await sql`
INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at")
VALUES (${randomUUID()}, 'new-subject', 'credential', ${credentialUserId}, now(), now())
`;
}, 30_000);
});
describeEmbeddedPostgres("provider identity migration with duplicate legacy keys", () => {
it("rejects ambiguous provider/account keys without changing legacy rows", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-account-duplicates-");
const sql = postgres(database.connectionString, { max: 1 });
try {
await sql`DROP INDEX "account_provider_account_id_uq"`;
await sql`ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL`;
await sql`CREATE UNIQUE INDEX "account_issuer_account_id_uq" ON "account" ("issuer", "account_id")`;
const userId = randomUUID();
await sql`INSERT INTO "user" ("id", "name", "email", "created_at", "updated_at")
VALUES (${userId}, 'Legacy User', 'legacy@example.com', now(), now())`;
await sql`INSERT INTO "account" ("id", "issuer", "account_id", "provider_id", "user_id", "created_at", "updated_at")
VALUES (${randomUUID()}, 'issuer-a', 'same-subject', 'credential', ${userId}, now(), now()),
(${randomUUID()}, 'issuer-b', 'same-subject', 'credential', ${userId}, now(), now())`;
const before = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`;
await expect(applySqlMigration(sql, "0465_better_auth_account_identity.sql"))
.rejects.toMatchObject({ code: "23505" });
expect(await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`).toEqual(before);
const [column] = await sql`SELECT "is_nullable" FROM "information_schema"."columns"
WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer'`;
expect(column?.is_nullable).toBe("NO");
} finally {
await sql.end();
await database.cleanup();
}
}, 30_000);
});
@@ -0,0 +1,3 @@
DROP INDEX "account_issuer_account_id_uq";--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "account_provider_account_id_uq" ON "account" USING btree ("provider_id","account_id");
@@ -1,6 +1,6 @@
{
"id": "f93ccf18-7e86-4788-be77-b17430c86b2f",
"prevId": "e070677d-cfb8-4438-847c-29c4da7ca8c5",
"id": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2",
"prevId": "e2a678da-05cc-4316-a40e-d85a69be01e3",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -6233,7 +6233,7 @@
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": false
},
"account_id": {
"name": "account_id",
@@ -6309,11 +6309,11 @@
}
},
"indexes": {
"account_issuer_account_id_uq": {
"name": "account_issuer_account_id_uq",
"account_provider_account_id_uq": {
"name": "account_provider_account_id_uq",
"columns": [
{
"expression": "issuer",
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -43580,6 +43580,28 @@
"method": "btree",
"with": {}
},
"issues_company_legacy_origin_run_idx": {
"name": "issues_company_legacy_origin_run_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "origin_run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"issues\".\"origin_issue_id\" is null and \"issues\".\"origin_run_id\" is not null",
"concurrently": true,
"method": "btree",
"with": {}
},
"issues_company_project_workspace_idx": {
"name": "issues_company_project_workspace_idx",
"columns": [
@@ -49085,6 +49107,93 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.plugin_lifecycle_resource_cursors": {
"name": "plugin_lifecycle_resource_cursors",
"schema": "",
"columns": {
"plugin_id": {
"name": "plugin_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"acknowledged_through_id": {
"name": "acknowledged_through_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": {
"name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk",
"tableFrom": "plugin_lifecycle_resource_cursors",
"tableTo": "plugins",
"columnsFrom": [
"plugin_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"plugin_lifecycle_resource_cursors_company_id_companies_id_fk": {
"name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk",
"tableFrom": "plugin_lifecycle_resource_cursors",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"plugin_lifecycle_resource_cursors_pk": {
"name": "plugin_lifecycle_resource_cursors_pk",
"columns": [
"plugin_id",
"company_id",
"resource_type",
"resource_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.plugin_logs": {
"name": "plugin_logs",
"schema": "",
@@ -50949,6 +51058,12 @@
"primaryKey": false,
"notNull": false
},
"environment_id": {
"name": "environment_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"git_authorization_grant_id": {
"name": "git_authorization_grant_id",
"type": "uuid",
@@ -51100,6 +51215,19 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"projects_environment_id_environments_id_fk": {
"name": "projects_environment_id_environments_id_fk",
"tableFrom": "projects",
"tableTo": "environments",
"columnsFrom": [
"environment_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"projects_git_authorization_grant_id_connection_grants_id_fk": {
"name": "projects_git_authorization_grant_id_connection_grants_id_fk",
"tableFrom": "projects",
@@ -59494,6 +59622,13 @@
"notNull": true,
"default": "''"
},
"pending_reveal": {
"name": "pending_reveal",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"allowed_actions": {
"name": "allowed_actions",
"type": "jsonb",
+7
View File
@@ -3242,6 +3242,13 @@
"when": 1794346237220,
"tag": "0464_project_environment",
"breakpoints": true
},
{
"idx": 465,
"version": "7",
"when": 1794400732201,
"tag": "0465_better_auth_account_identity",
"breakpoints": true
}
]
}
+2 -16
View File
@@ -21,25 +21,11 @@ export const authSessions = pgTable("session", {
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
});
/**
* Better Auth's `account` model. Every column here has to exist for the
* Drizzle adapter to accept the table at all: the adapter validates the
* model's fields against the schema on each write and throws
* `The field "<name>" does not exist in the "account" Drizzle schema`
* when one is missing, which surfaces as a 500 on sign-up.
*
* `issuer` is the account namespace Better Auth 1.7 added. It is
* `local:credential` for email/password accounts and
* `local:oauth:<providerId>` for social accounts that do not declare an
* issuer of their own, and it pairs with `account_id` as the stable
* provider-side key — hence the unique index, which mirrors the
* `(issuer, accountId)` unique index Better Auth declares on the model.
*/
export const authAccounts = pgTable(
"account",
{
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
issuer: text("issuer"),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
@@ -54,7 +40,7 @@ export const authAccounts = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
},
(table) => ({
issuerAccountIdUq: uniqueIndex("account_issuer_account_id_uq").on(table.issuer, table.accountId),
providerAccountIdUq: uniqueIndex("account_provider_account_id_uq").on(table.providerId, table.accountId),
}),
);
+1 -1
View File
@@ -90,7 +90,7 @@
"ai": "7.0.130",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"better-auth": "1.7.2",
"better-auth": "1.7.16",
"chat": "4.39.0",
"chokidar": "^5.0.0",
"compression": "^1.8.2",
@@ -28,11 +28,6 @@ const ORIGIN = "http://127.0.0.1:41999";
const EMAIL = "founder@example.com";
const PASSWORD = "correct-horse-battery-staple";
// The issuer Better Auth stamps on an email/password account:
// `createLocalAccountIssuer("credential")`. Sign-in matches on it, so a row
// written with anything else is a row nobody can sign in as.
const CREDENTIAL_ISSUER = "local:credential";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -107,7 +102,7 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
providerId: "credential",
issuer: CREDENTIAL_ISSUER,
issuer: null,
});
expect(accounts[0]?.password).toBeTruthy();
@@ -119,5 +114,17 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema
expect(signIn.status).toBe(200);
expect(signIn.body?.user?.email).toBe(EMAIL);
expect(sessionCookies(signIn).length).toBeGreaterThan(0);
await db.update(authAccounts).set({ issuer: "local:credential" });
const legacySignIn = await request(app)
.post("/api/auth/sign-in/email")
.set("origin", ORIGIN)
.send({ email: EMAIL, password: PASSWORD });
expect(legacySignIn.status).toBe(200);
expect(legacySignIn.body?.user?.email).toBe(EMAIL);
await expect(db.insert(authAccounts).values({ ...accounts[0]!, id: "duplicate-credential" }))
.rejects.toMatchObject({ cause: { code: "23505", constraint_name: "account_provider_account_id_uq" } });
await db.insert(authAccounts).values({ ...accounts[0]!, id: "other-provider", providerId: "other" });
});
});