// user.ts // // User model for authentication and authorization. // // Design notes: // - `id` is the stable internal identifier (UUID when database-backed) // - `email` is the primary human-facing identifier // - Roles provide coarse-grained authorization (admin, editor, etc.) // - Permissions provide fine-grained authorization (posts:create, etc.) // - Users can have both roles (which grant permissions) and direct permissions import { z } from "zod"; // Branded type for user IDs to prevent accidental mixing with other strings export type UserId = string & { readonly __brand: "UserId" }; // User account status const userStatusParser = z.enum(["active", "suspended", "pending"]); export type UserStatus = z.infer; // Role - simple string identifier const roleParser = z.string().min(1); export type Role = z.infer; // Permission format: "resource:action" e.g. "posts:create", "users:delete" const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, { message: "Permission must be in format 'resource:action'", }); export type Permission = z.infer; // Core user data schema - this is what gets stored/serialized const userDataParser = z.object({ id: z.string().min(1), email: z.email(), displayName: z.string().optional(), status: userStatusParser, roles: z.array(roleParser), permissions: z.array(permissionParser), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }); export type UserData = z.infer; // Role-to-permission mappings // In a real system this might be database-driven or configurable type RolePermissionMap = Map; const defaultRolePermissions: RolePermissionMap = new Map([ ["admin", ["users:read", "users:create", "users:update", "users:delete"]], ["user", ["users:read"]], ]); export class User { private readonly data: UserData; private 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; } get email(): string { return this.data.email; } get displayName(): string | undefined { return this.data.displayName; } // Status get status(): UserStatus { return this.data.status; } isActive(): boolean { return this.data.status === "active"; } // Roles get roles(): readonly Role[] { return this.data.roles; } hasRole(role: Role): boolean { return this.data.roles.includes(role); } hasAnyRole(roles: Role[]): boolean { return roles.some((role) => this.hasRole(role)); } hasAllRoles(roles: Role[]): boolean { return roles.every((role) => this.hasRole(role)); } // Permissions get permissions(): readonly Permission[] { return this.data.permissions; } // Get all permissions: direct + role-derived effectivePermissions(): Set { const perms = new Set(this.data.permissions); for (const role of this.data.roles) { const rolePerms = this.rolePermissions.get(role); if (rolePerms) { for (const p of rolePerms) { perms.add(p); } } } return perms; } // Check if user has a specific permission (direct or via role) hasPermission(permission: Permission): boolean { // Check direct permissions first if (this.data.permissions.includes(permission)) { return true; } // Check role-derived permissions for (const role of this.data.roles) { const rolePerms = this.rolePermissions.get(role); if (rolePerms?.includes(permission)) { return true; } } return false; } // Convenience method: can user perform action on resource? can(action: string, resource: string): boolean { const permission = `${resource}:${action}` as Permission; return this.hasPermission(permission); } // Timestamps get createdAt(): Date { return this.data.createdAt; } get updatedAt(): Date { return this.data.updatedAt; } // Serialization - returns plain object for storage/transmission toJSON(): UserData { return { ...this.data }; } } // For representing "no user" in contexts where user is optional export const AnonymousUser = Symbol("AnonymousUser"); export type MaybeUser = User | typeof AnonymousUser;