2 Commits

Author SHA1 Message Date
8704c4a8d5 Separate framework and app migrations
Also add a new develop command: clear-db.
2026-01-24 16:38:33 -06:00
579a19669e Match user and session schema changes 2026-01-24 15:48:22 -06:00
10 changed files with 287 additions and 91 deletions

View File

@@ -18,6 +18,7 @@ import type {
} from "./auth/store"; } from "./auth/store";
import { generateToken, hashToken } from "./auth/token"; import { generateToken, hashToken } from "./auth/token";
import type { SessionData, TokenId } from "./auth/types"; import type { SessionData, TokenId } from "./auth/types";
import type { Domain } from "./types";
import { AuthenticatedUser, type User, type UserId } from "./user"; import { AuthenticatedUser, type User, type UserId } from "./user";
// Connection configuration // Connection configuration
@@ -33,32 +34,52 @@ const connectionConfig = {
// Generated<T> marks columns with database defaults (optional on insert) // Generated<T> marks columns with database defaults (optional on insert)
interface UsersTable { interface UsersTable {
id: string; id: string;
email: string;
password_hash: string;
display_name: string | null;
status: Generated<string>; status: Generated<string>;
roles: Generated<string[]>; display_name: string | null;
permissions: Generated<string[]>; created_at: Generated<Date>;
email_verified: Generated<boolean>; updated_at: Generated<Date>;
}
interface UserEmailsTable {
id: string;
user_id: string;
email: string;
normalized_email: string;
is_primary: Generated<boolean>;
is_verified: Generated<boolean>;
created_at: Generated<Date>;
verified_at: Date | null;
revoked_at: Date | null;
}
interface UserCredentialsTable {
id: string;
user_id: string;
credential_type: Generated<string>;
password_hash: string | null;
created_at: Generated<Date>; created_at: Generated<Date>;
updated_at: Generated<Date>; updated_at: Generated<Date>;
} }
interface SessionsTable { interface SessionsTable {
token_id: string; id: Generated<string>;
token_hash: string;
user_id: string; user_id: string;
user_email_id: string | null;
token_type: string; token_type: string;
auth_method: string; auth_method: string;
created_at: Generated<Date>; created_at: Generated<Date>;
expires_at: Date; expires_at: Date;
last_used_at: Date | null; revoked_at: Date | null;
user_agent: string | null;
ip_address: string | null; ip_address: string | null;
user_agent: string | null;
is_used: Generated<boolean | null>; is_used: Generated<boolean | null>;
} }
interface Database { interface Database {
users: UsersTable; users: UsersTable;
user_emails: UserEmailsTable;
user_credentials: UserCredentialsTable;
sessions: SessionsTable; sessions: SessionsTable;
} }
@@ -92,7 +113,8 @@ async function raw<T = unknown>(
// //
// Migrations directory: express/migrations/ // Migrations directory: express/migrations/
const MIGRATIONS_DIR = path.join(__dirname, "migrations"); const FRAMEWORK_MIGRATIONS_DIR = path.join(__dirname, "framework/migrations");
const APP_MIGRATIONS_DIR = path.join(__dirname, "migrations");
const MIGRATIONS_TABLE = "_migrations"; const MIGRATIONS_TABLE = "_migrations";
interface MigrationRecord { interface MigrationRecord {
@@ -121,20 +143,30 @@ async function getAppliedMigrations(): Promise<string[]> {
} }
// Get pending migration files // Get pending migration files
function getMigrationFiles(): string[] { function getMigrationFiles(kind: Domain): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) { const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR;
if (!fs.existsSync(dir)) {
return []; return [];
} }
return fs
.readdirSync(MIGRATIONS_DIR) const root = __dirname;
const mm = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql")) .filter((f) => f.endsWith(".sql"))
.filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f)) .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f))
.map((f) => `${dir}/${f}`)
.map((f) => f.replace(`${root}/`, ""))
.sort(); .sort();
return mm;
} }
// Run a single migration // Run a single migration
async function runMigration(filename: string): Promise<void> { async function runMigration(filename: string): Promise<void> {
const filepath = path.join(MIGRATIONS_DIR, filename); // const filepath = path.join(MIGRATIONS_DIR, filename);
const filepath = filename;
const content = fs.readFileSync(filepath, "utf-8"); const content = fs.readFileSync(filepath, "utf-8");
process.stdout.write(` Migration: ${filename}...`); process.stdout.write(` Migration: ${filename}...`);
@@ -161,13 +193,21 @@ async function runMigration(filename: string): Promise<void> {
} }
} }
function getAllMigrationFiles() {
const fw_files = getMigrationFiles("fw");
const app_files = getMigrationFiles("app");
const all = [...fw_files, ...app_files];
return all;
}
// Run all pending migrations // Run all pending migrations
async function migrate(): Promise<void> { async function migrate(): Promise<void> {
await ensureMigrationsTable(); await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations()); const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles(); const all = getAllMigrationFiles();
const pending = files.filter((f) => !applied.has(f)); const pending = all.filter((all) => !applied.has(all));
if (pending.length === 0) { if (pending.length === 0) {
console.log("No pending migrations"); console.log("No pending migrations");
@@ -187,10 +227,10 @@ async function migrationStatus(): Promise<{
}> { }> {
await ensureMigrationsTable(); await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations()); const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles(); const ff = getAllMigrationFiles();
return { return {
applied: files.filter((f) => applied.has(f)), applied: ff.filter((ff) => applied.has(ff)),
pending: files.filter((f) => !applied.has(f)), pending: ff.filter((ff) => !applied.has(ff)),
}; };
} }
@@ -205,12 +245,12 @@ class PostgresAuthStore implements AuthStore {
data: CreateSessionData, data: CreateSessionData,
): Promise<{ token: string; session: SessionData }> { ): Promise<{ token: string; session: SessionData }> {
const token = generateToken(); const token = generateToken();
const tokenId = hashToken(token); const tokenHash = hashToken(token);
const row = await db const row = await db
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
token_id: tokenId, token_hash: tokenHash,
user_id: data.userId, user_id: data.userId,
token_type: data.tokenType, token_type: data.tokenType,
auth_method: data.authMethod, auth_method: data.authMethod,
@@ -222,13 +262,12 @@ class PostgresAuthStore implements AuthStore {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const session: SessionData = { const session: SessionData = {
tokenId: row.token_id, tokenId: row.token_hash,
userId: row.user_id, userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"], tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"], authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined, userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined, ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined, isUsed: row.is_used ?? undefined,
@@ -241,8 +280,9 @@ class PostgresAuthStore implements AuthStore {
const row = await db const row = await db
.selectFrom("sessions") .selectFrom("sessions")
.selectAll() .selectAll()
.where("token_id", "=", tokenId) .where("token_hash", "=", tokenId)
.where("expires_at", ">", new Date()) .where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -250,50 +290,62 @@ class PostgresAuthStore implements AuthStore {
} }
return { return {
tokenId: row.token_id, tokenId: row.token_hash,
userId: row.user_id, userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"], tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"], authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined, userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined, ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined, isUsed: row.is_used ?? undefined,
}; };
} }
async updateLastUsed(tokenId: TokenId): Promise<void> { async updateLastUsed(_tokenId: TokenId): Promise<void> {
await db // The new schema doesn't have last_used_at column
.updateTable("sessions") // This is now a no-op; session activity tracking could be added later
.set({ last_used_at: new Date() })
.where("token_id", "=", tokenId)
.execute();
} }
async deleteSession(tokenId: TokenId): Promise<void> { async deleteSession(tokenId: TokenId): Promise<void> {
// Soft delete by setting revoked_at
await db await db
.deleteFrom("sessions") .updateTable("sessions")
.where("token_id", "=", tokenId) .set({ revoked_at: new Date() })
.where("token_hash", "=", tokenId)
.execute(); .execute();
} }
async deleteUserSessions(userId: UserId): Promise<number> { async deleteUserSessions(userId: UserId): Promise<number> {
const result = await db const result = await db
.deleteFrom("sessions") .updateTable("sessions")
.set({ revoked_at: new Date() })
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
return Number(result.numDeletedRows); return Number(result.numUpdatedRows);
} }
// User operations // User operations
async getUserByEmail(email: string): Promise<User | null> { async getUserByEmail(email: string): Promise<User | null> {
// Find user through user_emails table
const normalizedEmail = email.toLowerCase().trim();
const row = await db const row = await db
.selectFrom("users") .selectFrom("user_emails")
.selectAll() .innerJoin("users", "users.id", "user_emails.user_id")
.where(sql`LOWER(email)`, "=", email.toLowerCase()) .select([
"users.id",
"users.status",
"users.display_name",
"users.created_at",
"users.updated_at",
"user_emails.email",
])
.where("user_emails.normalized_email", "=", normalizedEmail)
.where("user_emails.revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -303,10 +355,24 @@ class PostgresAuthStore implements AuthStore {
} }
async getUserById(userId: UserId): Promise<User | null> { async getUserById(userId: UserId): Promise<User | null> {
// Get user with their primary email
const row = await db const row = await db
.selectFrom("users") .selectFrom("users")
.selectAll() .leftJoin("user_emails", (join) =>
.where("id", "=", userId) join
.onRef("user_emails.user_id", "=", "users.id")
.on("user_emails.is_primary", "=", true)
.on("user_emails.revoked_at", "is", null),
)
.select([
"users.id",
"users.status",
"users.display_name",
"users.created_at",
"users.updated_at",
"user_emails.email",
])
.where("users.id", "=", userId)
.executeTakeFirst(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -316,68 +382,149 @@ class PostgresAuthStore implements AuthStore {
} }
async createUser(data: CreateUserData): Promise<User> { async createUser(data: CreateUserData): Promise<User> {
const id = crypto.randomUUID(); const userId = crypto.randomUUID();
const emailId = crypto.randomUUID();
const credentialId = crypto.randomUUID();
const now = new Date(); const now = new Date();
const normalizedEmail = data.email.toLowerCase().trim();
const row = await db // Create user record
await db
.insertInto("users") .insertInto("users")
.values({ .values({
id, id: userId,
email: data.email,
password_hash: data.passwordHash,
display_name: data.displayName ?? null, display_name: data.displayName ?? null,
status: "pending", status: "pending",
roles: [],
permissions: [],
email_verified: false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}) })
.returningAll() .execute();
.executeTakeFirstOrThrow();
return this.rowToUser(row); // Create user_email record
await db
.insertInto("user_emails")
.values({
id: emailId,
user_id: userId,
email: data.email,
normalized_email: normalizedEmail,
is_primary: true,
is_verified: false,
created_at: now,
})
.execute();
// Create user_credential record
await db
.insertInto("user_credentials")
.values({
id: credentialId,
user_id: userId,
credential_type: "password",
password_hash: data.passwordHash,
created_at: now,
updated_at: now,
})
.execute();
return new AuthenticatedUser({
id: userId,
email: data.email,
displayName: data.displayName,
status: "pending",
roles: [],
permissions: [],
createdAt: now,
updatedAt: now,
});
} }
async getUserPasswordHash(userId: UserId): Promise<string | null> { async getUserPasswordHash(userId: UserId): Promise<string | null> {
const row = await db const row = await db
.selectFrom("users") .selectFrom("user_credentials")
.select("password_hash") .select("password_hash")
.where("id", "=", userId) .where("user_id", "=", userId)
.where("credential_type", "=", "password")
.executeTakeFirst(); .executeTakeFirst();
return row?.password_hash ?? null; return row?.password_hash ?? null;
} }
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> { async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
const now = new Date();
// Try to update existing credential
const result = await db
.updateTable("user_credentials")
.set({ password_hash: passwordHash, updated_at: now })
.where("user_id", "=", userId)
.where("credential_type", "=", "password")
.executeTakeFirst();
// If no existing credential, create one
if (Number(result.numUpdatedRows) === 0) {
await db
.insertInto("user_credentials")
.values({
id: crypto.randomUUID(),
user_id: userId,
credential_type: "password",
password_hash: passwordHash,
created_at: now,
updated_at: now,
})
.execute();
}
// Update user's updated_at
await db await db
.updateTable("users") .updateTable("users")
.set({ password_hash: passwordHash, updated_at: new Date() }) .set({ updated_at: now })
.where("id", "=", userId) .where("id", "=", userId)
.execute(); .execute();
} }
async updateUserEmailVerified(userId: UserId): Promise<void> { async updateUserEmailVerified(userId: UserId): Promise<void> {
const now = new Date();
// Update user_emails to mark as verified
await db
.updateTable("user_emails")
.set({
is_verified: true,
verified_at: now,
})
.where("user_id", "=", userId)
.where("is_primary", "=", true)
.execute();
// Update user status to active
await db await db
.updateTable("users") .updateTable("users")
.set({ .set({
email_verified: true,
status: "active", status: "active",
updated_at: new Date(), updated_at: now,
}) })
.where("id", "=", userId) .where("id", "=", userId)
.execute(); .execute();
} }
// Helper to convert database row to User object // Helper to convert database row to User object
private rowToUser(row: Selectable<UsersTable>): User { private rowToUser(row: {
id: string;
status: string;
display_name: string | null;
created_at: Date;
updated_at: Date;
email: string | null;
}): User {
return new AuthenticatedUser({ return new AuthenticatedUser({
id: row.id, id: row.id,
email: row.email, email: row.email ?? "unknown@example.com",
displayName: row.display_name ?? undefined, displayName: row.display_name ?? undefined,
status: row.status as "active" | "suspended" | "pending", status: row.status as "active" | "suspended" | "pending",
roles: row.roles, roles: [], // TODO: query from RBAC tables
permissions: row.permissions, permissions: [], // TODO: query from RBAC tables
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}); });

View File

@@ -0,0 +1,17 @@
import { connectionConfig, migrate, pool } from "../database";
import { dropTables, exitIfUnforced } from "./util";
async function main(): Promise<void> {
exitIfUnforced();
try {
await dropTables();
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to clear database:", err.message);
process.exit(1);
});

View File

@@ -1,38 +1,14 @@
// reset-db.ts // reset-db.ts
// Development command to wipe the database and apply all migrations from scratch // Development command to wipe the database and apply all migrations from scratch
import { migrate, pool, connectionConfig } from "../database"; import { connectionConfig, migrate, pool } from "../database";
import { dropTables, exitIfUnforced } from "./util";
async function main(): Promise<void> { async function main(): Promise<void> {
const args = process.argv.slice(2); exitIfUnforced();
// Require explicit confirmation unless --force is passed
if (!args.includes("--force")) {
console.error("This will DROP ALL TABLES in the database!");
console.error(` Database: ${connectionConfig.database}`);
console.error(` Host: ${connectionConfig.host}:${connectionConfig.port}`);
console.error("");
console.error("Run with --force to proceed.");
process.exit(1);
}
try { try {
console.log("Dropping all tables..."); await dropTables();
// Get all table names in the public schema
const result = await pool.query<{ tablename: string }>(`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
`);
if (result.rows.length > 0) {
// Drop all tables with CASCADE to handle foreign key constraints
const tableNames = result.rows.map((r) => `"${r.tablename}"`).join(", ");
await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`);
console.log(`Dropped ${result.rows.length} table(s)`);
} else {
console.log("No tables to drop");
}
console.log(""); console.log("");
await migrate(); await migrate();

42
express/develop/util.ts Normal file
View File

@@ -0,0 +1,42 @@
// FIXME: this is at the wrong level of specificity
import { connectionConfig, migrate, pool } from "../database";
const exitIfUnforced = () => {
const args = process.argv.slice(2);
// Require explicit confirmation unless --force is passed
if (!args.includes("--force")) {
console.error("This will DROP ALL TABLES in the database!");
console.error(` Database: ${connectionConfig.database}`);
console.error(
` Host: ${connectionConfig.host}:${connectionConfig.port}`,
);
console.error("");
console.error("Run with --force to proceed.");
process.exit(1);
}
};
const dropTables = async () => {
console.log("Dropping all tables...");
// Get all table names in the public schema
const result = await pool.query<{ tablename: string }>(`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
`);
if (result.rows.length > 0) {
// Drop all tables with CASCADE to handle foreign key constraints
const tableNames = result.rows
.map((r) => `"${r.tablename}"`)
.join(", ");
await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`);
console.log(`Dropped ${result.rows.length} table(s)`);
} else {
console.log("No tables to drop");
}
};
export { dropTables, exitIfUnforced };

View File

@@ -2,7 +2,8 @@
-- Create sessions table for auth tokens -- Create sessions table for auth tokens
CREATE TABLE sessions ( CREATE TABLE sessions (
id UUID PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id), user_id UUID NOT NULL REFERENCES users(id),
user_email_id UUID REFERENCES user_emails(id), user_email_id UUID REFERENCES user_emails(id),
token_type TEXT NOT NULL, token_type TEXT NOT NULL,

View File

@@ -112,4 +112,6 @@ export function requirePermission(call: Call, permission: Permission): User {
return user; return user;
} }
export type Domain = "app" | "fw";
export { methodParser, massageMethod }; export { methodParser, massageMethod };

11
framework/develop.d/clear-db Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# This file belongs to the framework. You are not expected to modify it.
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$DIR/../.."
cd "$ROOT/express"
"$DIR"/../cmd.d/tsx develop/clear-db.ts "$@"