From d9216790586b0b2d823dbee28fcb981f3fb91f83 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 17:45:36 -0600 Subject: [PATCH] Rework user types: create AuthenticatedUser and AnonymousUser class Both are subclasses of an abstract User class which contains almost everything interesting. --- express/auth/service.ts | 17 ++++--- express/auth/store.ts | 6 +-- express/auth/types.ts | 8 ++-- express/basic/routes.ts | 9 +++- express/context.ts | 8 ++-- express/database.ts | 4 +- express/request/index.ts | 4 +- express/types.ts | 11 ++--- express/user.ts | 97 +++++++++++++++++++++++++++------------- 9 files changed, 102 insertions(+), 62 deletions(-) diff --git a/express/auth/service.ts b/express/auth/service.ts index e8d9025..677f1d7 100644 --- a/express/auth/service.ts +++ b/express/auth/service.ts @@ -4,7 +4,12 @@ // password reset, and email verification. import type { Request as ExpressRequest } from "express"; -import { AnonymousUser, type User, type UserId } from "../user"; +import { + type AnonymousUser, + anonymousUser, + type User, + type UserId, +} from "../user"; import { hashPassword, verifyPassword } from "./password"; import type { AuthStore } from "./store"; import { @@ -27,7 +32,7 @@ type SimpleResult = { success: true } | { success: false; error: string }; // Result of validating a request/token - contains both user and session export type AuthResult = | { authenticated: true; user: User; session: SessionData } - | { authenticated: false; user: typeof AnonymousUser; session: null }; + | { authenticated: false; user: AnonymousUser; session: null }; export class AuthService { constructor(private store: AuthStore) {} @@ -83,7 +88,7 @@ export class AuthService { } if (!token) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } return this.validateToken(token); @@ -94,16 +99,16 @@ export class AuthService { const session = await this.store.getSession(tokenId); if (!session) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } if (session.tokenType !== "session") { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } const user = await this.store.getUserById(session.userId as UserId); if (!user || !user.isActive()) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } // Update last used (fire and forget) diff --git a/express/auth/store.ts b/express/auth/store.ts index f3f2684..b9dbccd 100644 --- a/express/auth/store.ts +++ b/express/auth/store.ts @@ -3,7 +3,7 @@ // Authentication storage interface and in-memory implementation. // The interface allows easy migration to PostgreSQL later. -import { User, type UserId } from "../user"; +import { AuthenticatedUser, type User, type UserId } from "../user"; import { generateToken, hashToken } from "./token"; import type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; @@ -123,7 +123,7 @@ export class InMemoryAuthStore implements AuthStore { } async createUser(data: CreateUserData): Promise { - const user = User.create(data.email, { + const user = AuthenticatedUser.create(data.email, { displayName: data.displayName, status: "pending", // Pending until email verified }); @@ -151,7 +151,7 @@ export class InMemoryAuthStore implements AuthStore { const user = this.users.get(userId); if (user) { // Create new user with active status - const updatedUser = User.create(user.email, { + const updatedUser = AuthenticatedUser.create(user.email, { id: user.id, displayName: user.displayName, status: "active", diff --git a/express/auth/types.ts b/express/auth/types.ts index c8112dd..997c4aa 100644 --- a/express/auth/types.ts +++ b/express/auth/types.ts @@ -64,17 +64,17 @@ export const tokenLifetimes: Record = { }; // Import here to avoid circular dependency at module load time -import { AnonymousUser, type MaybeUser } from "../user"; +import type { User } from "../user"; // Session wrapper class providing a consistent interface for handlers. // Always present on Call (never null), but may represent an anonymous session. export class Session { constructor( private readonly data: SessionData | null, - private readonly user: MaybeUser, + private readonly user: User, ) {} - getUser(): MaybeUser { + getUser(): User { return this.user; } @@ -83,7 +83,7 @@ export class Session { } isAuthenticated(): boolean { - return this.user !== AnonymousUser; + return !this.user.isAnonymous(); } get tokenId(): string | undefined { diff --git a/express/basic/routes.ts b/express/basic/routes.ts index c051397..e042c43 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -24,7 +24,14 @@ const routes: Record = { const me = request.session.getUser(); const email = me.toString(); - const c = await render("basic/home", { email }); + const showLogin = me.isAnonymous(); + const showLogout = !me.isAnonymous(); + + const c = await render("basic/home", { + email, + showLogin, + showLogout, + }); return html(c); }, diff --git a/express/context.ts b/express/context.ts index 9f52711..2fdd9ef 100644 --- a/express/context.ts +++ b/express/context.ts @@ -5,10 +5,10 @@ // needing to pass Call through every function. import { AsyncLocalStorage } from "node:async_hooks"; -import { AnonymousUser, type MaybeUser } from "./user"; +import { anonymousUser, type User } from "./user"; type RequestContext = { - user: MaybeUser; + user: User; }; const asyncLocalStorage = new AsyncLocalStorage(); @@ -19,9 +19,9 @@ function runWithContext(context: RequestContext, fn: () => T): T { } // Get the current user from context, or AnonymousUser if not in a request -function getCurrentUser(): MaybeUser { +function getCurrentUser(): User { const context = asyncLocalStorage.getStore(); - return context?.user ?? AnonymousUser; + return context?.user ?? anonymousUser; } export { getCurrentUser, runWithContext, type RequestContext }; diff --git a/express/database.ts b/express/database.ts index a5b317d..a932a40 100644 --- a/express/database.ts +++ b/express/database.ts @@ -18,7 +18,7 @@ import type { } from "./auth/store"; import { generateToken, hashToken } from "./auth/token"; import type { SessionData, TokenId } from "./auth/types"; -import { User, type UserId } from "./user"; +import { AuthenticatedUser, type User, type UserId } from "./user"; // Connection configuration const connectionConfig = { @@ -367,7 +367,7 @@ class PostgresAuthStore implements AuthStore { // Helper to convert database row to User object private rowToUser(row: Selectable): User { - return new User({ + return new AuthenticatedUser({ id: row.id, email: row.email, displayName: row.display_name ?? undefined, diff --git a/express/request/index.ts b/express/request/index.ts index 6f98811..466a992 100644 --- a/express/request/index.ts +++ b/express/request/index.ts @@ -1,13 +1,13 @@ import { AuthService } from "../auth"; import { getCurrentUser } from "../context"; import { PostgresAuthStore } from "../database"; -import type { MaybeUser } from "../user"; +import type { User } from "../user"; import { html, redirect, render } from "./util"; const util = { html, redirect, render }; const session = { - getUser: (): MaybeUser => { + getUser: (): User => { return getCurrentUser(); }, }; diff --git a/express/types.ts b/express/types.ts index 222db51..e62b77c 100644 --- a/express/types.ts +++ b/express/types.ts @@ -8,12 +8,7 @@ import { z } from "zod"; import type { Session } from "./auth/types"; import type { ContentType } from "./content-types"; import type { HttpCode } from "./http-codes"; -import { - AnonymousUser, - type MaybeUser, - type Permission, - type User, -} from "./user"; +import type { Permission, User } from "./user"; const methodParser = z.union([ z.literal("GET"), @@ -36,7 +31,7 @@ export type Call = { method: Method; parameters: object; request: ExpressRequest; - user: MaybeUser; + user: User; session: Session; }; @@ -102,7 +97,7 @@ export class AuthorizationDenied extends Error { // Helper for handlers to require authentication export function requireAuth(call: Call): User { - if (call.user === AnonymousUser) { + if (call.user.isAnonymous()) { throw new AuthenticationRequired(); } return call.user; diff --git a/express/user.ts b/express/user.ts index 6c32646..1803350 100644 --- a/express/user.ts +++ b/express/user.ts @@ -51,39 +51,15 @@ const defaultRolePermissions: RolePermissionMap = new Map([ ["user", ["users:read"]], ]); -export class User { - private readonly data: UserData; - private rolePermissions: RolePermissionMap; +export abstract class User { + protected readonly data: UserData; + protected rolePermissions: RolePermissionMap; constructor(data: UserData, rolePermissions?: RolePermissionMap) { this.data = userDataParser.parse(data); this.rolePermissions = rolePermissions ?? defaultRolePermissions; } - // Factory for creating new users with sensible defaults - static create( - email: string, - options?: { - id?: string; - displayName?: string; - status?: UserStatus; - roles?: Role[]; - permissions?: Permission[]; - }, - ): User { - const now = new Date(); - return new User({ - id: options?.id ?? crypto.randomUUID(), - email, - displayName: options?.displayName, - status: options?.status ?? "active", - roles: options?.roles ?? [], - permissions: options?.permissions ?? [], - createdAt: now, - updatedAt: now, - }); - } - // Identity get id(): UserId { return this.data.id as UserId; @@ -185,15 +161,72 @@ export class User { toString(): string { return `User(id ${this.id})`; } + + abstract isAnonymous(): boolean; +} + +export class AuthenticatedUser extends User { + // Factory for creating new users with sensible defaults + static create( + email: string, + options?: { + id?: string; + displayName?: string; + status?: UserStatus; + roles?: Role[]; + permissions?: Permission[]; + }, + ): User { + const now = new Date(); + return new AuthenticatedUser({ + id: options?.id ?? crypto.randomUUID(), + email, + displayName: options?.displayName, + status: options?.status ?? "active", + roles: options?.roles ?? [], + permissions: options?.permissions ?? [], + createdAt: now, + updatedAt: now, + }); + } + + isAnonymous(): boolean { + return false; + } } // For representing "no user" in contexts where user is optional -export const AnonymousUser = Symbol("AnonymousUser"); +export class AnonymousUser extends User { + // FIXME: this is C&Ped with only minimal changes. No bueno. + static create( + email: string, + options?: { + id?: string; + displayName?: string; + status?: UserStatus; + roles?: Role[]; + permissions?: Permission[]; + }, + ): AnonymousUser { + const now = new Date(0); + return new AnonymousUser({ + id: options?.id ?? crypto.randomUUID(), + email, + displayName: options?.displayName, + status: options?.status ?? "active", + roles: options?.roles ?? [], + permissions: options?.permissions ?? [], + createdAt: now, + updatedAt: now, + }); + } -export const anonymousUser = User.create("anonymous@example.com", { + isAnonymous(): boolean { + return true; + } +} + +export const anonymousUser = AnonymousUser.create("anonymous@example.com", { id: "-1", displayName: "Anonymous User", - // FIXME: set createdAt and updatedAt to start of epoch }); - -export type MaybeUser = User | typeof AnonymousUser;