Compare commits
2 Commits
474420ac1e
...
8704c4a8d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 8704c4a8d5 | |||
| 579a19669e |
@@ -18,6 +18,7 @@ import type {
|
||||
} from "./auth/store";
|
||||
import { generateToken, hashToken } from "./auth/token";
|
||||
import type { SessionData, TokenId } from "./auth/types";
|
||||
import type { Domain } from "./types";
|
||||
import { AuthenticatedUser, type User, type UserId } from "./user";
|
||||
|
||||
// Connection configuration
|
||||
@@ -33,32 +34,52 @@ const connectionConfig = {
|
||||
// Generated<T> marks columns with database defaults (optional on insert)
|
||||
interface UsersTable {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
display_name: string | null;
|
||||
status: Generated<string>;
|
||||
roles: Generated<string[]>;
|
||||
permissions: Generated<string[]>;
|
||||
email_verified: Generated<boolean>;
|
||||
display_name: string | null;
|
||||
created_at: Generated<Date>;
|
||||
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>;
|
||||
updated_at: Generated<Date>;
|
||||
}
|
||||
|
||||
interface SessionsTable {
|
||||
token_id: string;
|
||||
id: Generated<string>;
|
||||
token_hash: string;
|
||||
user_id: string;
|
||||
user_email_id: string | null;
|
||||
token_type: string;
|
||||
auth_method: string;
|
||||
created_at: Generated<Date>;
|
||||
expires_at: Date;
|
||||
last_used_at: Date | null;
|
||||
user_agent: string | null;
|
||||
revoked_at: Date | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
is_used: Generated<boolean | null>;
|
||||
}
|
||||
|
||||
interface Database {
|
||||
users: UsersTable;
|
||||
user_emails: UserEmailsTable;
|
||||
user_credentials: UserCredentialsTable;
|
||||
sessions: SessionsTable;
|
||||
}
|
||||
|
||||
@@ -92,7 +113,8 @@ async function raw<T = unknown>(
|
||||
//
|
||||
// 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";
|
||||
|
||||
interface MigrationRecord {
|
||||
@@ -121,20 +143,30 @@ async function getAppliedMigrations(): Promise<string[]> {
|
||||
}
|
||||
|
||||
// Get pending migration files
|
||||
function getMigrationFiles(): string[] {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||
function getMigrationFiles(kind: Domain): string[] {
|
||||
const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR;
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
return fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
|
||||
const root = __dirname;
|
||||
|
||||
const mm = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f))
|
||||
.map((f) => `${dir}/${f}`)
|
||||
.map((f) => f.replace(`${root}/`, ""))
|
||||
.sort();
|
||||
|
||||
return mm;
|
||||
}
|
||||
|
||||
// Run a single migration
|
||||
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");
|
||||
|
||||
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
|
||||
async function migrate(): Promise<void> {
|
||||
await ensureMigrationsTable();
|
||||
|
||||
const applied = new Set(await getAppliedMigrations());
|
||||
const files = getMigrationFiles();
|
||||
const pending = files.filter((f) => !applied.has(f));
|
||||
const all = getAllMigrationFiles();
|
||||
const pending = all.filter((all) => !applied.has(all));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log("No pending migrations");
|
||||
@@ -187,10 +227,10 @@ async function migrationStatus(): Promise<{
|
||||
}> {
|
||||
await ensureMigrationsTable();
|
||||
const applied = new Set(await getAppliedMigrations());
|
||||
const files = getMigrationFiles();
|
||||
const ff = getAllMigrationFiles();
|
||||
return {
|
||||
applied: files.filter((f) => applied.has(f)),
|
||||
pending: files.filter((f) => !applied.has(f)),
|
||||
applied: ff.filter((ff) => applied.has(ff)),
|
||||
pending: ff.filter((ff) => !applied.has(ff)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,12 +245,12 @@ class PostgresAuthStore implements AuthStore {
|
||||
data: CreateSessionData,
|
||||
): Promise<{ token: string; session: SessionData }> {
|
||||
const token = generateToken();
|
||||
const tokenId = hashToken(token);
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const row = await db
|
||||
.insertInto("sessions")
|
||||
.values({
|
||||
token_id: tokenId,
|
||||
token_hash: tokenHash,
|
||||
user_id: data.userId,
|
||||
token_type: data.tokenType,
|
||||
auth_method: data.authMethod,
|
||||
@@ -222,13 +262,12 @@ class PostgresAuthStore implements AuthStore {
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const session: SessionData = {
|
||||
tokenId: row.token_id,
|
||||
tokenId: row.token_hash,
|
||||
userId: row.user_id,
|
||||
tokenType: row.token_type as SessionData["tokenType"],
|
||||
authMethod: row.auth_method as SessionData["authMethod"],
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastUsedAt: row.last_used_at ?? undefined,
|
||||
userAgent: row.user_agent ?? undefined,
|
||||
ipAddress: row.ip_address ?? undefined,
|
||||
isUsed: row.is_used ?? undefined,
|
||||
@@ -241,8 +280,9 @@ class PostgresAuthStore implements AuthStore {
|
||||
const row = await db
|
||||
.selectFrom("sessions")
|
||||
.selectAll()
|
||||
.where("token_id", "=", tokenId)
|
||||
.where("token_hash", "=", tokenId)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
@@ -250,50 +290,62 @@ class PostgresAuthStore implements AuthStore {
|
||||
}
|
||||
|
||||
return {
|
||||
tokenId: row.token_id,
|
||||
tokenId: row.token_hash,
|
||||
userId: row.user_id,
|
||||
tokenType: row.token_type as SessionData["tokenType"],
|
||||
authMethod: row.auth_method as SessionData["authMethod"],
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastUsedAt: row.last_used_at ?? undefined,
|
||||
userAgent: row.user_agent ?? undefined,
|
||||
ipAddress: row.ip_address ?? undefined,
|
||||
isUsed: row.is_used ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async updateLastUsed(tokenId: TokenId): Promise<void> {
|
||||
await db
|
||||
.updateTable("sessions")
|
||||
.set({ last_used_at: new Date() })
|
||||
.where("token_id", "=", tokenId)
|
||||
.execute();
|
||||
async updateLastUsed(_tokenId: TokenId): Promise<void> {
|
||||
// The new schema doesn't have last_used_at column
|
||||
// This is now a no-op; session activity tracking could be added later
|
||||
}
|
||||
|
||||
async deleteSession(tokenId: TokenId): Promise<void> {
|
||||
// Soft delete by setting revoked_at
|
||||
await db
|
||||
.deleteFrom("sessions")
|
||||
.where("token_id", "=", tokenId)
|
||||
.updateTable("sessions")
|
||||
.set({ revoked_at: new Date() })
|
||||
.where("token_hash", "=", tokenId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteUserSessions(userId: UserId): Promise<number> {
|
||||
const result = await db
|
||||
.deleteFrom("sessions")
|
||||
.updateTable("sessions")
|
||||
.set({ revoked_at: new Date() })
|
||||
.where("user_id", "=", userId)
|
||||
.where("revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numDeletedRows);
|
||||
return Number(result.numUpdatedRows);
|
||||
}
|
||||
|
||||
// User operations
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
// Find user through user_emails table
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
const row = await db
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where(sql`LOWER(email)`, "=", email.toLowerCase())
|
||||
.selectFrom("user_emails")
|
||||
.innerJoin("users", "users.id", "user_emails.user_id")
|
||||
.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();
|
||||
|
||||
if (!row) {
|
||||
@@ -303,10 +355,24 @@ class PostgresAuthStore implements AuthStore {
|
||||
}
|
||||
|
||||
async getUserById(userId: UserId): Promise<User | null> {
|
||||
// Get user with their primary email
|
||||
const row = await db
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where("id", "=", userId)
|
||||
.leftJoin("user_emails", (join) =>
|
||||
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();
|
||||
|
||||
if (!row) {
|
||||
@@ -316,68 +382,149 @@ class PostgresAuthStore implements AuthStore {
|
||||
}
|
||||
|
||||
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 normalizedEmail = data.email.toLowerCase().trim();
|
||||
|
||||
const row = await db
|
||||
// Create user record
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id,
|
||||
email: data.email,
|
||||
password_hash: data.passwordHash,
|
||||
id: userId,
|
||||
display_name: data.displayName ?? null,
|
||||
status: "pending",
|
||||
roles: [],
|
||||
permissions: [],
|
||||
email_verified: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
.execute();
|
||||
|
||||
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> {
|
||||
const row = await db
|
||||
.selectFrom("users")
|
||||
.selectFrom("user_credentials")
|
||||
.select("password_hash")
|
||||
.where("id", "=", userId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("credential_type", "=", "password")
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.password_hash ?? null;
|
||||
}
|
||||
|
||||
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
|
||||
.updateTable("users")
|
||||
.set({ password_hash: passwordHash, updated_at: new Date() })
|
||||
.set({ updated_at: now })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
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
|
||||
.updateTable("users")
|
||||
.set({
|
||||
email_verified: true,
|
||||
status: "active",
|
||||
updated_at: new Date(),
|
||||
updated_at: now,
|
||||
})
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// 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({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
email: row.email ?? "unknown@example.com",
|
||||
displayName: row.display_name ?? undefined,
|
||||
status: row.status as "active" | "suspended" | "pending",
|
||||
roles: row.roles,
|
||||
permissions: row.permissions,
|
||||
roles: [], // TODO: query from RBAC tables
|
||||
permissions: [], // TODO: query from RBAC tables
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
|
||||
17
express/develop/clear-db.ts
Normal file
17
express/develop/clear-db.ts
Normal 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);
|
||||
});
|
||||
@@ -1,38 +1,14 @@
|
||||
// reset-db.ts
|
||||
// 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> {
|
||||
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);
|
||||
}
|
||||
exitIfUnforced();
|
||||
|
||||
try {
|
||||
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");
|
||||
}
|
||||
await dropTables();
|
||||
|
||||
console.log("");
|
||||
await migrate();
|
||||
|
||||
42
express/develop/util.ts
Normal file
42
express/develop/util.ts
Normal 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 };
|
||||
@@ -2,7 +2,8 @@
|
||||
-- Create sessions table for auth tokens
|
||||
|
||||
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_email_id UUID REFERENCES user_emails(id),
|
||||
token_type TEXT NOT NULL,
|
||||
@@ -112,4 +112,6 @@ export function requirePermission(call: Call, permission: Permission): User {
|
||||
return user;
|
||||
}
|
||||
|
||||
export type Domain = "app" | "fw";
|
||||
|
||||
export { methodParser, massageMethod };
|
||||
|
||||
11
framework/develop.d/clear-db
Executable file
11
framework/develop.d/clear-db
Executable 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 "$@"
|
||||
Reference in New Issue
Block a user