Migrate authentication to Better Auth

Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-29 19:07:04 +00:00
co-authored by Claude Opus 5
parent 4afa5d6fa0
commit 733d2459df
47 changed files with 2430 additions and 1585 deletions
+200
View File
@@ -0,0 +1,200 @@
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
import {
pgTable,
uuid,
varchar,
text as pgText,
timestamp,
boolean as pgBoolean,
bigint,
customType as pgCustomType,
} from 'drizzle-orm/pg-core';
// Better Auth table definitions for both dialects.
//
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
// foreign key that references them) survive the auth migration untouched. Only
// the columns Better Auth reads/writes are declared here; legacy columns
// (password, google_id, token_version) stay physically present but invisible
// to Better Auth. The full application-facing definition lives in schema.ts —
// two Drizzle table objects can safely describe the same SQL table.
const dbType = process.env.DB_TYPE || 'sqlite';
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
dataType() {
return 'text';
},
toDriver(value: Date): string {
return (value instanceof Date ? value : new Date(value)).toISOString();
},
fromDriver(value: string): Date {
return new Date(value);
},
});
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
dataType() {
return 'integer';
},
toDriver(value: boolean): number {
return value ? 1 : 0;
},
fromDriver(value: number | boolean): boolean {
return Boolean(value);
},
});
// ==================== SQLite ====================
export const sqliteAuthUsers = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: isoText('created_at').notNull(),
updatedAt: isoText('updated_at').notNull(),
// admin plugin fields
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
// application additionalFields
phone: text('phone'),
languagePreference: text('language_preference'),
rucNumber: text('ruc_number'),
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
accountStatus: text('account_status').notNull().default('active'),
});
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
// admin plugin (impersonation)
impersonatedBy: text('impersonated_by'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request'),
});
// ==================== PostgreSQL ====================
export const pgAuthUsers = pgTable('users', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
// admin plugin fields
role: varchar('role', { length: 20 }).notNull().default('user'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
// application additionalFields
phone: varchar('phone', { length: 50 }),
languagePreference: varchar('language_preference', { length: 10 }),
rucNumber: varchar('ruc_number', { length: 15 }),
isClaimed: pgIntBool('is_claimed').notNull(),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
});
export const pgAuthSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: pgText('user_agent'),
// admin plugin (impersonation)
impersonatedBy: uuid('impersonated_by'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthAccounts = pgTable('auth_accounts', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
accountId: varchar('account_id', { length: 255 }).notNull(),
providerId: varchar('provider_id', { length: 100 }).notNull(),
accessToken: pgText('access_token'),
refreshToken: pgText('refresh_token'),
idToken: pgText('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: pgText('scope'),
password: pgText('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthVerifications = pgTable('auth_verifications', {
id: uuid('id').primaryKey(),
identifier: varchar('identifier', { length: 255 }).notNull(),
value: pgText('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
id: varchar('id', { length: 64 }).primaryKey(),
key: varchar('key', { length: 255 }),
count: bigint('count', { mode: 'number' }),
lastRequest: bigint('last_request', { mode: 'number' }),
});
// ==================== Runtime-switched exports ====================
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
// rows, run migrate repeatedly, and assert the backfill is correct and never
// duplicates.
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
const dbPath = join(dir, 'migrate.db');
function runMigrate() {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: {
...process.env,
DB_TYPE: 'sqlite',
DATABASE_URL: dbPath,
REDIS_URL: '',
},
stdio: 'pipe',
});
}
let db: Database.Database;
beforeAll(() => {
// First run creates the schema
runMigrate();
db = new Database(dbPath);
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
// google_id links Google, '' marks guest accounts, suspended via status.
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
);
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
// Second run performs the backfill against the seeded rows
runMigrate();
}, 240_000);
describe('Better Auth migration backfill', () => {
it('creates credential accounts for users with real passwords only', () => {
const rows = db
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
.all() as any[];
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
expect(byUser.get('legacy-both')).toContain('$argon2id$');
expect(byUser.get('legacy-suspended')).toBeTruthy();
// Guests ('' password) and Google-only users get no credential account
expect(byUser.has('legacy-guest')).toBe(false);
expect(byUser.has('legacy-google')).toBe(false);
});
it('creates google accounts from google_id', () => {
const rows = db
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
.all() as any[];
expect(rows).toEqual([
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
]);
});
it('marks claimed legacy users email-verified, guests not', () => {
const verified = (email: string) =>
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
expect(verified('argon@old.py')).toBe(1);
expect(verified('google@old.py')).toBe(1);
expect(verified('guest@old.py')).toBe(0);
});
it('mirrors suspended accounts to banned', () => {
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
expect(row.banned).toBe(1);
expect(row.ban_reason).toContain('suspended');
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
expect(active.banned).toBe(0);
});
it('is idempotent: a third run adds nothing', () => {
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
const before = count();
runMigrate();
expect(count()).toBe(before);
}, 60_000);
});
+229
View File
@@ -544,6 +544,81 @@ async function migrate() {
updated_by TEXT REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table).
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
impersonated_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id TEXT PRIMARY KEY,
key TEXT,
count INTEGER,
last_request INTEGER
)
`);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -1044,6 +1119,80 @@ async function migrate() {
updated_by UUID REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
impersonated_by UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(100) NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
password TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id UUID PRIMARY KEY,
identifier VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id VARCHAR(64) PRIMARY KEY,
key VARCHAR(255),
count BIGINT,
last_request BIGINT
)
`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
@@ -1056,6 +1205,11 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
];
for (const stmt of indexStatements) {
try {
@@ -1067,6 +1221,81 @@ async function migrate() {
} catch (e) { /* index may already exist */ }
}
// ==================== Better Auth data backfill ====================
// Idempotent: every statement is guarded so re-running migrate is safe, and
// legacy users are distinguished from Better-Auth-created users by having
// users.password / users.google_id set (Better Auth never writes either).
if (dbType === 'sqlite') {
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
// both stay valid via the custom password verifier in lib/betterAuth.ts)
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
// Legacy Google links -> google provider accounts
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
// Claimed legacy accounts proved their email (register/claim link/Google)
await (db as any).run(sql`
UPDATE users SET email_verified = 1
WHERE email_verified = 0 AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
await (db as any).run(sql`
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = 0
`);
} else {
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
await (db as any).execute(sql`
UPDATE users SET email_verified = TRUE
WHERE email_verified = FALSE AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
await (db as any).execute(sql`
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = FALSE
`);
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+13 -1
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
// Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', {
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -380,6 +386,12 @@ export const pgUsers = pgTable('users', {
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});