fix(#1395): accounts.issuer column + credential-only backfill
ci/woodpecker/pr/ci Pipeline was successful

better-auth >=1.7 (1.7.1 measured on web1; our ^1.5.5 range resolves it
on fresh installs) requires an issuer on every account row: sign-up
writes the synthetic 'local:credential' and sign-in filters accounts on
(providerId='credential' AND issuer=that value). The db schema had no
issuer column, so every fresh next install 500s at signup and 401s every
sign-in while admin-token auth kept working.

- schema: accounts.issuer text, nullable — the 1.5.x line our lockfile
  resolves to does not know the field (1.5 ignores, 1.7 populates; one
  schema serves both)
- migration 0017 (drizzle-kit generated snapshot/ALTER, hand backfill
  folded in): ADD COLUMN issuer text; UPDATE credential rows with NULL
  issuer to 'local:credential'. OAuth/sso rows deliberately left NULL —
  better-auth owns their issuer semantics going forward
- tests: greenfield column assertion (nullable, text); UPGRADE-PATH arm
  replays 0000..0016, seeds one credential + one oauth row without
  issuer, applies 0017, and discriminates: credential backfilled,
  oauth stays NULL
- mutants killed: backfill dropped -> upgrade arm fails; backfill-all ->
  oauth assertion fails

Found by T63 on web1 greenfield; verified there as a dist-patch (prior
art). Root cause, error strings, and the post-fix 200 measured by fred;
independently re-derived here against @better-auth/[email protected]'s schema
exports (issuer exists ONLY on account — user/session/verifications
carry no issuer field).
This commit is contained in:
2026-08-24 17:09:33 -05:00
parent d7b1dd9601
commit bd16e3ca0a
5 changed files with 4628 additions and 1 deletions
@@ -0,0 +1,9 @@
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
--> statement-breakpoint
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
-- credential rows predate the column and would fail that filter on upgraded
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
-- oauth/sso rows going forward (each provider's real issuer value), so those
-- stay NULL until the provider's next flow writes them.
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1784050648841, "when": 1784050648841,
"tag": "0016_salty_morlocks", "tag": "0016_salty_morlocks",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787609223282,
"tag": "0017_accounts_issuer",
"breakpoints": true
} }
] ]
} }
+69
View File
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined(); await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
}); });
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
await runPgliteMigrations(handle);
const result = (await handle.db.execute(sql`
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'accounts' AND column_name = 'issuer'
`)) as unknown as {
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
};
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
// not write the field; 1.7+ populates it. One schema serves both.
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.is_nullable).toBe('YES');
expect(result.rows[0]?.data_type).toBe('text');
});
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
// rows (one credential, one oauth), then apply 0017 and discriminate.
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
// table gates re-application, so a plain replay of 0000..0016 is enough.
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.join(import.meta.dirname, '..', 'drizzle');
const files = fs
.readdirSync(dir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
.sort();
for (const f of files) {
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
for (const stmt of raw.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
}
await client.exec(`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
VALUES
('a1', '[email protected]', 'credential', 'u1', now(), now()),
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
`);
// Apply 0017 (column + backfill).
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
for (const stmt of sql0017.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
const rows = (await handle.db.execute(sql`
SELECT provider_id, issuer FROM accounts ORDER BY id
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
// Credential rows get better-auth's synthetic local issuer — the value
// sign-in filters on (better-auth dist createLocalAccountIssuer).
expect(byProvider.get('credential')).toBe('local:credential');
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
// forward (each provider's real issuer on its next flow).
expect(byProvider.get('google')).toBeNull();
});
it('surfaces statement-level error context on failure and leaves no ledger row', async () => { it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE, // Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
// forcing it to fail without IF NOT EXISTS. // forcing it to fail without IF NOT EXISTS.
+6
View File
@@ -63,6 +63,12 @@ export const accounts = pgTable(
id: text('id').primaryKey(), id: text('id').primaryKey(),
accountId: text('account_id').notNull(), accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(), providerId: text('provider_id').notNull(),
// better-auth >=1.7 requires an issuer on every account row: credential
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
// provider's real issuer, and sign-in filters on (providerId, issuer).
// Nullable because the 1.5.x line this repo's lockfile resolves to does
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
issuer: text('issuer'),
userId: text('user_id') userId: text('user_id')
.notNull() .notNull()
.references(() => users.id, { onDelete: 'cascade' }), .references(() => users.id, { onDelete: 'cascade' }),