first commit
This commit is contained in:
715
packages/auth/src/adapters/kysely.ts
Normal file
715
packages/auth/src/adapters/kysely.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* Kysely database adapter for @emdashcms/auth
|
||||
*/
|
||||
|
||||
import type { Kysely, Insertable, Selectable, Updateable } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import {
|
||||
Role,
|
||||
toRoleLevel,
|
||||
toDeviceType,
|
||||
toTokenType,
|
||||
type AuthAdapter,
|
||||
type User,
|
||||
type NewUser,
|
||||
type UpdateUser,
|
||||
type Credential,
|
||||
type NewCredential,
|
||||
type AuthToken,
|
||||
type NewAuthToken,
|
||||
type TokenType,
|
||||
type OAuthAccount,
|
||||
type NewOAuthAccount,
|
||||
type AllowedDomain,
|
||||
type RoleLevel,
|
||||
} from "../types.js";
|
||||
|
||||
// ============================================================================
|
||||
// Database schema types
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthTables {
|
||||
users: UserTable;
|
||||
credentials: CredentialTable;
|
||||
auth_tokens: AuthTokenTable;
|
||||
oauth_accounts: OAuthAccountTable;
|
||||
allowed_domains: AllowedDomainTable;
|
||||
}
|
||||
|
||||
interface UserTable {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: number;
|
||||
email_verified: number;
|
||||
disabled: number;
|
||||
data: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CredentialTable {
|
||||
id: string;
|
||||
user_id: string;
|
||||
public_key: Uint8Array;
|
||||
counter: number;
|
||||
device_type: string;
|
||||
backed_up: number;
|
||||
transports: string | null;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
last_used_at: string;
|
||||
}
|
||||
|
||||
interface AuthTokenTable {
|
||||
hash: string;
|
||||
user_id: string | null;
|
||||
email: string | null;
|
||||
type: string;
|
||||
role: number | null;
|
||||
invited_by: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface OAuthAccountTable {
|
||||
provider: string;
|
||||
provider_account_id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AllowedDomainTable {
|
||||
domain: string;
|
||||
default_role: number;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Adapter implementation
|
||||
// ============================================================================
|
||||
|
||||
export function createKyselyAdapter<T extends AuthTables>(db: Kysely<T>): AuthAdapter {
|
||||
// Type cast to work with generic Kysely instance
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- generic Kysely<T extends AuthTables> narrowed to concrete AuthTables for internal queries
|
||||
const kdb = db as unknown as Kysely<AuthTables>;
|
||||
|
||||
return {
|
||||
// ========================================================================
|
||||
// Users
|
||||
// ========================================================================
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const row = await kdb.selectFrom("users").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
|
||||
return row ? rowToUser(row) : null;
|
||||
},
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const row = await kdb
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where("email", "=", email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? rowToUser(row) : null;
|
||||
},
|
||||
|
||||
async createUser(user: NewUser): Promise<User> {
|
||||
const now = new Date().toISOString();
|
||||
const id = ulid();
|
||||
|
||||
const row: Insertable<UserTable> = {
|
||||
id,
|
||||
email: user.email.toLowerCase(),
|
||||
name: user.name ?? null,
|
||||
avatar_url: user.avatarUrl ?? null,
|
||||
role: user.role ?? Role.SUBSCRIBER,
|
||||
email_verified: user.emailVerified ? 1 : 0,
|
||||
disabled: 0,
|
||||
data: user.data ? JSON.stringify(user.data) : null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await kdb.insertInto("users").values(row).execute();
|
||||
|
||||
return {
|
||||
id,
|
||||
email: row.email,
|
||||
name: user.name ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
role: toRoleLevel(row.role),
|
||||
emailVerified: row.email_verified === 1,
|
||||
disabled: false,
|
||||
data: user.data ?? null,
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now),
|
||||
};
|
||||
},
|
||||
|
||||
async updateUser(id: string, data: UpdateUser): Promise<void> {
|
||||
const update: Updateable<UserTable> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (data.email !== undefined) update.email = data.email.toLowerCase();
|
||||
if (data.name !== undefined) update.name = data.name;
|
||||
if (data.avatarUrl !== undefined) update.avatar_url = data.avatarUrl;
|
||||
if (data.role !== undefined) update.role = data.role;
|
||||
if (data.emailVerified !== undefined) update.email_verified = data.emailVerified ? 1 : 0;
|
||||
if (data.disabled !== undefined) update.disabled = data.disabled ? 1 : 0;
|
||||
if (data.data !== undefined) update.data = data.data ? JSON.stringify(data.data) : null;
|
||||
|
||||
await kdb.updateTable("users").set(update).where("id", "=", id).execute();
|
||||
},
|
||||
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
await kdb.deleteFrom("users").where("id", "=", id).execute();
|
||||
},
|
||||
|
||||
async countUsers(): Promise<number> {
|
||||
const result = await kdb
|
||||
.selectFrom("users")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async getUsers(options?: {
|
||||
search?: string;
|
||||
role?: number;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
items: Array<
|
||||
User & {
|
||||
lastLogin: Date | null;
|
||||
credentialCount: number;
|
||||
oauthProviders: string[];
|
||||
}
|
||||
>;
|
||||
nextCursor?: string;
|
||||
}> {
|
||||
const limit = Math.min(options?.limit ?? 20, 100);
|
||||
|
||||
let query = kdb
|
||||
.selectFrom("users")
|
||||
.leftJoin("credentials", "users.id", "credentials.user_id")
|
||||
.selectAll("users")
|
||||
.select((eb) => [
|
||||
eb.fn.count<number>("credentials.id").as("credential_count"),
|
||||
eb.fn.max("credentials.last_used_at").as("last_login"),
|
||||
])
|
||||
.groupBy("users.id")
|
||||
.orderBy("users.created_at", "desc")
|
||||
.limit(limit + 1);
|
||||
|
||||
// Apply filters
|
||||
if (options?.search) {
|
||||
const searchPattern = `%${options.search}%`;
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb("users.email", "like", searchPattern),
|
||||
eb("users.name", "like", searchPattern),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.role !== undefined) {
|
||||
query = query.where("users.role", "=", options.role);
|
||||
}
|
||||
|
||||
if (options?.cursor) {
|
||||
// Get the cursor user's created_at for pagination
|
||||
const cursorUser = await kdb
|
||||
.selectFrom("users")
|
||||
.select("created_at")
|
||||
.where("id", "=", options.cursor)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (cursorUser) {
|
||||
query = query.where("users.created_at", "<", cursorUser.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await query.execute();
|
||||
|
||||
// Get OAuth providers for all users in this batch
|
||||
const userIds = rows.slice(0, limit).map((r) => r.id);
|
||||
const oauthAccounts =
|
||||
userIds.length > 0
|
||||
? await kdb
|
||||
.selectFrom("oauth_accounts")
|
||||
.select(["user_id", "provider"])
|
||||
.where("user_id", "in", userIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
// Group OAuth providers by user
|
||||
const oauthByUser = new Map<string, string[]>();
|
||||
for (const account of oauthAccounts) {
|
||||
const providers = oauthByUser.get(account.user_id) ?? [];
|
||||
providers.push(account.provider);
|
||||
oauthByUser.set(account.user_id, providers);
|
||||
}
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = rows.slice(0, limit).map((row) => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatarUrl: row.avatar_url,
|
||||
role: toRoleLevel(row.role),
|
||||
emailVerified: row.email_verified === 1,
|
||||
disabled: row.disabled === 1,
|
||||
data: row.data ? JSON.parse(row.data) : null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
lastLogin: row.last_login ? new Date(row.last_login) : null,
|
||||
credentialCount: row.credential_count ?? 0,
|
||||
oauthProviders: oauthByUser.get(row.id) ?? [],
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: hasMore ? items.at(-1)?.id : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
async getUserWithDetails(id: string): Promise<{
|
||||
user: User;
|
||||
credentials: Credential[];
|
||||
oauthAccounts: OAuthAccount[];
|
||||
lastLogin: Date | null;
|
||||
} | null> {
|
||||
const user = await kdb
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const [credentials, oauthAccounts] = await Promise.all([
|
||||
kdb
|
||||
.selectFrom("credentials")
|
||||
.selectAll()
|
||||
.where("user_id", "=", id)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute(),
|
||||
kdb.selectFrom("oauth_accounts").selectAll().where("user_id", "=", id).execute(),
|
||||
]);
|
||||
|
||||
// Find last login from most recent credential use
|
||||
const lastLogin = credentials.reduce<Date | null>((latest, cred) => {
|
||||
const lastUsed = new Date(cred.last_used_at);
|
||||
return !latest || lastUsed > latest ? lastUsed : latest;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
user: rowToUser(user),
|
||||
credentials: credentials.map(rowToCredential),
|
||||
oauthAccounts: oauthAccounts.map(rowToOAuthAccount),
|
||||
lastLogin,
|
||||
};
|
||||
},
|
||||
|
||||
async countAdmins(): Promise<number> {
|
||||
const result = await kdb
|
||||
.selectFrom("users")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("role", "=", Role.ADMIN)
|
||||
.where("disabled", "=", 0)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Credentials
|
||||
// ========================================================================
|
||||
|
||||
async getCredentialById(id: string): Promise<Credential | null> {
|
||||
const row = await kdb
|
||||
.selectFrom("credentials")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? rowToCredential(row) : null;
|
||||
},
|
||||
|
||||
async getCredentialsByUserId(userId: string): Promise<Credential[]> {
|
||||
const rows = await kdb
|
||||
.selectFrom("credentials")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
return rows.map(rowToCredential);
|
||||
},
|
||||
|
||||
async createCredential(credential: NewCredential): Promise<Credential> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const row: Insertable<CredentialTable> = {
|
||||
id: credential.id,
|
||||
user_id: credential.userId,
|
||||
public_key: credential.publicKey,
|
||||
counter: credential.counter,
|
||||
device_type: credential.deviceType,
|
||||
backed_up: credential.backedUp ? 1 : 0,
|
||||
transports: credential.transports.length > 0 ? JSON.stringify(credential.transports) : null,
|
||||
name: credential.name ?? null,
|
||||
created_at: now,
|
||||
last_used_at: now,
|
||||
};
|
||||
|
||||
await kdb.insertInto("credentials").values(row).execute();
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
userId: credential.userId,
|
||||
publicKey: credential.publicKey,
|
||||
counter: credential.counter,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: credential.backedUp,
|
||||
transports: credential.transports,
|
||||
name: credential.name ?? null,
|
||||
createdAt: new Date(now),
|
||||
lastUsedAt: new Date(now),
|
||||
};
|
||||
},
|
||||
|
||||
async updateCredentialCounter(id: string, counter: number): Promise<void> {
|
||||
await kdb
|
||||
.updateTable("credentials")
|
||||
.set({
|
||||
counter,
|
||||
last_used_at: new Date().toISOString(),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
},
|
||||
|
||||
async updateCredentialName(id: string, name: string | null): Promise<void> {
|
||||
await kdb.updateTable("credentials").set({ name }).where("id", "=", id).execute();
|
||||
},
|
||||
|
||||
async deleteCredential(id: string): Promise<void> {
|
||||
await kdb.deleteFrom("credentials").where("id", "=", id).execute();
|
||||
},
|
||||
|
||||
async countCredentialsByUserId(userId: string): Promise<number> {
|
||||
const result = await kdb
|
||||
.selectFrom("credentials")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.count;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Auth Tokens
|
||||
// ========================================================================
|
||||
|
||||
async createToken(token: NewAuthToken): Promise<void> {
|
||||
const row: Insertable<AuthTokenTable> = {
|
||||
hash: token.hash,
|
||||
user_id: token.userId ?? null,
|
||||
email: token.email ?? null,
|
||||
type: token.type,
|
||||
role: token.role ?? null,
|
||||
invited_by: token.invitedBy ?? null,
|
||||
expires_at: token.expiresAt.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await kdb.insertInto("auth_tokens").values(row).execute();
|
||||
},
|
||||
|
||||
async getToken(hash: string, type: TokenType): Promise<AuthToken | null> {
|
||||
const row = await kdb
|
||||
.selectFrom("auth_tokens")
|
||||
.selectAll()
|
||||
.where("hash", "=", hash)
|
||||
.where("type", "=", type)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? rowToAuthToken(row) : null;
|
||||
},
|
||||
|
||||
async deleteToken(hash: string): Promise<void> {
|
||||
await kdb.deleteFrom("auth_tokens").where("hash", "=", hash).execute();
|
||||
},
|
||||
|
||||
async deleteExpiredTokens(): Promise<void> {
|
||||
await kdb
|
||||
.deleteFrom("auth_tokens")
|
||||
.where("expires_at", "<", new Date().toISOString())
|
||||
.execute();
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// OAuth Accounts
|
||||
// ========================================================================
|
||||
|
||||
async getOAuthAccount(
|
||||
provider: string,
|
||||
providerAccountId: string,
|
||||
): Promise<OAuthAccount | null> {
|
||||
const row = await kdb
|
||||
.selectFrom("oauth_accounts")
|
||||
.selectAll()
|
||||
.where("provider", "=", provider)
|
||||
.where("provider_account_id", "=", providerAccountId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? rowToOAuthAccount(row) : null;
|
||||
},
|
||||
|
||||
async getOAuthAccountsByUserId(userId: string): Promise<OAuthAccount[]> {
|
||||
const rows = await kdb
|
||||
.selectFrom("oauth_accounts")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
return rows.map(rowToOAuthAccount);
|
||||
},
|
||||
|
||||
async createOAuthAccount(account: NewOAuthAccount): Promise<OAuthAccount> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const row: Insertable<OAuthAccountTable> = {
|
||||
provider: account.provider,
|
||||
provider_account_id: account.providerAccountId,
|
||||
user_id: account.userId,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
await kdb.insertInto("oauth_accounts").values(row).execute();
|
||||
|
||||
return {
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
userId: account.userId,
|
||||
createdAt: new Date(now),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteOAuthAccount(provider: string, providerAccountId: string): Promise<void> {
|
||||
await kdb
|
||||
.deleteFrom("oauth_accounts")
|
||||
.where("provider", "=", provider)
|
||||
.where("provider_account_id", "=", providerAccountId)
|
||||
.execute();
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Allowed Domains
|
||||
// ========================================================================
|
||||
|
||||
async getAllowedDomain(domain: string): Promise<AllowedDomain | null> {
|
||||
const row = await kdb
|
||||
.selectFrom("allowed_domains")
|
||||
.selectAll()
|
||||
.where("domain", "=", domain.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ? rowToAllowedDomain(row) : null;
|
||||
},
|
||||
|
||||
async getAllowedDomains(): Promise<AllowedDomain[]> {
|
||||
const rows = await kdb.selectFrom("allowed_domains").selectAll().execute();
|
||||
|
||||
return rows.map(rowToAllowedDomain);
|
||||
},
|
||||
|
||||
async createAllowedDomain(domain: string, defaultRole: RoleLevel): Promise<AllowedDomain> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const row: Insertable<AllowedDomainTable> = {
|
||||
domain: domain.toLowerCase(),
|
||||
default_role: defaultRole,
|
||||
enabled: 1,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
await kdb.insertInto("allowed_domains").values(row).execute();
|
||||
|
||||
return {
|
||||
domain: row.domain,
|
||||
defaultRole,
|
||||
enabled: true,
|
||||
createdAt: new Date(now),
|
||||
};
|
||||
},
|
||||
|
||||
async updateAllowedDomain(
|
||||
domain: string,
|
||||
enabled: boolean,
|
||||
defaultRole?: RoleLevel,
|
||||
): Promise<void> {
|
||||
const update: Updateable<AllowedDomainTable> = {
|
||||
enabled: enabled ? 1 : 0,
|
||||
};
|
||||
|
||||
if (defaultRole !== undefined) {
|
||||
update.default_role = defaultRole;
|
||||
}
|
||||
|
||||
await kdb
|
||||
.updateTable("allowed_domains")
|
||||
.set(update)
|
||||
.where("domain", "=", domain.toLowerCase())
|
||||
.execute();
|
||||
},
|
||||
|
||||
async deleteAllowedDomain(domain: string): Promise<void> {
|
||||
await kdb.deleteFrom("allowed_domains").where("domain", "=", domain.toLowerCase()).execute();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Row converters
|
||||
// ============================================================================
|
||||
|
||||
function rowToUser(row: Selectable<UserTable>): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatarUrl: row.avatar_url,
|
||||
role: toRoleLevel(row.role),
|
||||
emailVerified: row.email_verified === 1,
|
||||
disabled: row.disabled === 1,
|
||||
data: row.data ? JSON.parse(row.data) : null,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCredential(row: Selectable<CredentialTable>): Credential {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
publicKey: row.public_key,
|
||||
counter: row.counter,
|
||||
deviceType: toDeviceType(row.device_type),
|
||||
backedUp: row.backed_up === 1,
|
||||
transports: row.transports ? JSON.parse(row.transports) : [],
|
||||
name: row.name,
|
||||
createdAt: new Date(row.created_at),
|
||||
lastUsedAt: new Date(row.last_used_at),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToAuthToken(row: Selectable<AuthTokenTable>): AuthToken {
|
||||
return {
|
||||
hash: row.hash,
|
||||
userId: row.user_id,
|
||||
email: row.email,
|
||||
type: toTokenType(row.type),
|
||||
role: row.role != null ? toRoleLevel(row.role) : null,
|
||||
invitedBy: row.invited_by,
|
||||
expiresAt: new Date(row.expires_at),
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToOAuthAccount(row: Selectable<OAuthAccountTable>): OAuthAccount {
|
||||
return {
|
||||
provider: row.provider,
|
||||
providerAccountId: row.provider_account_id,
|
||||
userId: row.user_id,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToAllowedDomain(row: Selectable<AllowedDomainTable>): AllowedDomain {
|
||||
return {
|
||||
domain: row.domain,
|
||||
defaultRole: toRoleLevel(row.default_role),
|
||||
enabled: row.enabled === 1,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration SQL
|
||||
// ============================================================================
|
||||
|
||||
export const AUTH_TABLES_SQL = `
|
||||
-- Users (no password_hash)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role INTEGER NOT NULL DEFAULT 10,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
-- Passkey credentials
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key BLOB NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT NOT NULL,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_user ON credentials(user_id);
|
||||
|
||||
-- Auth tokens (magic links, email verification, invites)
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
hash TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
type TEXT NOT NULL,
|
||||
role INTEGER,
|
||||
invited_by TEXT REFERENCES users(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_email ON auth_tokens(email);
|
||||
|
||||
-- OAuth accounts (external provider links)
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
provider TEXT NOT NULL,
|
||||
provider_account_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, provider_account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
|
||||
|
||||
-- Allowed domains for self-signup
|
||||
CREATE TABLE IF NOT EXISTS allowed_domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
default_role INTEGER NOT NULL DEFAULT 20,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
Reference in New Issue
Block a user