UNPKG

userdo

Version:

A Durable Object base class for building applications on Cloudflare Workers.

633 lines (632 loc) 25.8 kB
import { DurableObject } from 'cloudflare:workers'; import { z } from 'zod'; import jwt from '@tsndr/cloudflare-worker-jwt'; import { UserDODatabase } from './database/index.js'; // --- User Schema --- const UserSchema = z.object({ id: z.string(), email: z.string().email(), passwordHash: z.string(), salt: z.string(), createdAt: z.string(), refreshTokens: z.array(z.string()).default([]), }); // --- Organization Schemas --- const OrganizationSchema = z.object({ id: z.string(), name: z.string().min(1), ownerId: z.string(), createdAt: z.string(), }); const OrganizationMemberSchema = z.object({ id: z.string(), organizationId: z.string(), userId: z.string(), email: z.string().email(), role: z.enum(['admin', 'member']), // owner is implicit createdAt: z.string(), }); const OrganizationMembershipSchema = z.object({ organizationId: z.string(), organizationName: z.string(), ownerEmail: z.string(), role: z.enum(['admin', 'member']), joinedAt: z.string(), }); // --- Zod Schemas for endpoint validation --- const SignupSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); const LoginSchema = SignupSchema; const InitSchema = UserSchema; // --- Password Hashing --- const PASSWORD_CONFIG = { iterations: 100_000, saltLength: 16, }; const RESERVED_PREFIX = "__"; const AUTH_DATA_KEY = "__user"; const RATE_LIMIT_KEY = "__rl"; const RATE_LIMIT_MAX = 5; const RATE_LIMIT_WINDOW = 60_000; // 1 minute function isReservedKey(key) { return key.startsWith(RESERVED_PREFIX); } // Hash email for use as DO ID to prevent PII leaking in logs export async function hashEmailForId(email) { const encoder = new TextEncoder(); const data = encoder.encode(email.toLowerCase()); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = new Uint8Array(hashBuffer); const hashHex = Array.from(hashArray) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hashHex; } // Helper function to get UserDO // Maintains the same API as env.MY_APP_DO.get(env.MY_APP_DO.idFromName(email)) export function getUserDO(namespace, email) { return namespace.get(namespace.idFromName(email)); } const getDO = (env, email) => { return env.USERDO.get(env.USERDO.idFromName(email)); }; async function hashPassword(password) { const encoder = new TextEncoder(); const saltBytes = crypto.getRandomValues(new Uint8Array(PASSWORD_CONFIG.saltLength)); const salt = btoa(String.fromCharCode(...saltBytes)); const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']); const derivedBits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltBytes, iterations: PASSWORD_CONFIG.iterations, hash: 'SHA-256' }, key, 256); const hash = btoa(String.fromCharCode(...new Uint8Array(derivedBits))); return { hash, salt }; } async function verifyPassword(password, salt, expectedHash) { const encoder = new TextEncoder(); const saltBytes = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']); const derivedBits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltBytes, iterations: PASSWORD_CONFIG.iterations, hash: 'SHA-256' }, key, 256); const hash = btoa(String.fromCharCode(...new Uint8Array(derivedBits))); return hash === expectedHash; } // Atomic migration helper (outside the class) export async function migrateUserEmail({ env, oldEmail, newEmail }) { oldEmail = oldEmail.toLowerCase(); newEmail = newEmail.toLowerCase(); const oldDO = getDO(env, oldEmail); const newDO = getDO(env, newEmail); try { const user = await oldDO.raw(); user.email = newEmail; await newDO.init(user); await oldDO.deleteUser(); return { ok: true }; } catch (err) { // Optionally, add rollback logic here return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } export class UserDO extends DurableObject { constructor(state, env) { super(state, env); this.state = state; this.storage = state.storage; this.env = env; this.database = new UserDODatabase(this.storage, this.getCurrentUserId(), this.broadcast.bind(this)); // Initialize organization tables this.ownedOrganizations = this.table('owned_organizations', OrganizationSchema, { userScoped: true }); this.organizationMembers = this.table('organization_members', OrganizationMemberSchema, { userScoped: true }); } async checkRateLimit() { const now = Date.now(); const record = await this.storage.get(RATE_LIMIT_KEY); if (record && record.resetAt > now) { if (record.count >= RATE_LIMIT_MAX) { throw new Error('Too many requests'); } record.count += 1; await this.storage.put(RATE_LIMIT_KEY, record); } else { const resetAt = now + RATE_LIMIT_WINDOW; await this.storage.put(RATE_LIMIT_KEY, { count: 1, resetAt }); } } async generateTokens(user) { const accessExp = Math.floor(Date.now() / 1000) + 15 * 60; const token = await jwt.sign({ sub: user.id, email: user.email, exp: accessExp, }, this.env.JWT_SECRET); const refreshExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; const refreshToken = await jwt.sign({ sub: user.id, type: 'refresh', exp: refreshExp, }, this.env.JWT_SECRET); return { token, refreshToken }; } async signup({ email, password }) { email = email.toLowerCase(); await this.checkRateLimit(); const parsed = SignupSchema.safeParse({ email, password }); if (!parsed.success) { throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten())); } // Check if user already exists const existing = await this.storage.get(AUTH_DATA_KEY); if (existing) throw new Error('Email already registered'); const id = this.state.id.toString(); const createdAt = new Date().toISOString(); const { hash, salt } = await hashPassword(password); const user = { id, email, passwordHash: hash, salt, createdAt, refreshTokens: [] }; await this.storage.put(AUTH_DATA_KEY, user); const { token, refreshToken } = await this.generateTokens(user); // Store refresh token if (!user.refreshTokens) user.refreshTokens = []; user.refreshTokens.push(refreshToken); await this.storage.put(AUTH_DATA_KEY, user); return { user, token, refreshToken }; } async login({ email, password }) { email = email.toLowerCase(); await this.checkRateLimit(); const parsed = LoginSchema.safeParse({ email, password }); if (!parsed.success) { throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten())); } const user = await this.storage.get(AUTH_DATA_KEY); if (!user || user.email !== email) throw new Error('Invalid credentials'); const ok = await verifyPassword(password, user.salt, user.passwordHash); if (!ok) throw new Error('Invalid credentials'); const { token, refreshToken } = await this.generateTokens(user); // Store refresh token if (!user.refreshTokens) user.refreshTokens = []; user.refreshTokens.push(refreshToken); await this.storage.put(AUTH_DATA_KEY, user); return { user, token, refreshToken }; } async raw() { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); return user; } async init(user) { const parsed = InitSchema.safeParse(user); if (!parsed.success) { throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten())); } await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } async deleteUser() { await this.storage.delete(AUTH_DATA_KEY); return { ok: true }; } // Change password method async changePassword({ oldPassword, newPassword }) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Validate old password const ok = await verifyPassword(oldPassword, user.salt, user.passwordHash); if (!ok) throw new Error('Invalid current password'); // Validate new password const parsed = SignupSchema.shape.password.safeParse(newPassword); if (!parsed.success) { throw new Error('Invalid new password: ' + JSON.stringify(parsed.error.flatten())); } // Hash new password const { hash, salt } = await hashPassword(newPassword); user.passwordHash = hash; user.salt = salt; await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } // Reset password method (for use after verifying a reset token) async resetPassword({ newPassword }) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Validate new password const parsed = SignupSchema.shape.password.safeParse(newPassword); if (!parsed.success) { throw new Error('Invalid new password: ' + JSON.stringify(parsed.error.flatten())); } // Hash new password const { hash, salt } = await hashPassword(newPassword); user.passwordHash = hash; user.salt = salt; await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } // Generate password reset token (expires in 1 hour) async generatePasswordResetToken() { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); const resetExp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour const resetToken = await jwt.sign({ sub: user.id, email: user.email, type: 'password_reset', exp: resetExp }, this.env.JWT_SECRET); return { resetToken }; } // Reset password with token async resetPasswordWithToken({ resetToken, newPassword }) { try { const verify = await jwt.verify(resetToken, this.env.JWT_SECRET); if (!verify || !verify.payload || verify.payload.type !== 'password_reset') { throw new Error('Invalid reset token'); } const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Validate new password const parsed = SignupSchema.shape.password.safeParse(newPassword); if (!parsed.success) { throw new Error('Invalid new password: ' + JSON.stringify(parsed.error.flatten())); } // Hash new password const { hash, salt } = await hashPassword(newPassword); user.passwordHash = hash; user.salt = salt; await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } catch (err) { throw new Error('Invalid reset token'); } } async verifyToken({ token }) { try { const verify = await jwt.verify(token, this.env.JWT_SECRET); if (!verify) throw new Error('Invalid token'); const { payload } = verify; if (!payload) throw new Error('Invalid token'); const { sub, email } = payload; if (!sub || !email) throw new Error('Invalid token'); const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); if (payload.sub !== user.id) { throw new Error('Token subject mismatch'); } return { ok: true, user: { id: user.id, email: user.email } }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } async set(key, value) { if (isReservedKey(key)) { throw new Error(`Key "${key}" is reserved`); } await this.storage.put(key, value); this.broadcast(`kv:${key}`, value); return { ok: true }; } async get(key) { if (isReservedKey(key)) { throw new Error(`Key "${key}" is reserved`); } return await this.storage.get(key); } async refreshToken({ refreshToken }) { try { const verify = await jwt.verify(refreshToken, this.env.JWT_SECRET); if (!verify || !verify.payload || verify.payload.type !== 'refresh') { throw new Error('Invalid refresh token'); } const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Verify refresh token is in user's list if (!user.refreshTokens.includes(refreshToken)) { throw new Error('Refresh token not found'); } // Generate new access token const accessExp = Math.floor(Date.now() / 1000) + 15 * 60; const token = await jwt.sign({ sub: user.id, email: user.email, exp: accessExp }, this.env.JWT_SECRET); return { token }; } catch (err) { throw new Error('Invalid refresh token'); } } async revokeRefreshToken({ refreshToken }) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); user.refreshTokens = user.refreshTokens.filter(token => token !== refreshToken); await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } async revokeAllRefreshTokens() { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); user.refreshTokens = []; await this.storage.put(AUTH_DATA_KEY, user); return { ok: true }; } async logout() { return this.revokeAllRefreshTokens(); } // === Organization Management === setOrganizationContext(organizationId) { this.database.setOrganizationContext(organizationId); } async createOrganization(name) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); const organization = { id: crypto.randomUUID(), name, ownerId: user.id, createdAt: new Date().toISOString(), }; // Store organization in my UserDO (I own it) await this.ownedOrganizations.create(organization); this.broadcast('organization:created', { organization }); return { organization }; } async getOrganizations() { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Get organizations I own const ownedOrganizations = await this.ownedOrganizations.getAll(); // Get organizations I'm a member of const memberOrganizations = await this.storage.get('organization_memberships') || []; return { organizations: ownedOrganizations, memberOrganizations }; } async getOrganization(organizationId) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // First check if I own this organization const ownedOrg = await this.ownedOrganizations.findById(organizationId); if (ownedOrg) { // I own it - get members from my UserDO const members = await this.organizationMembers.where('organizationId', '==', organizationId).get(); return { organization: ownedOrg, members, isOwner: true }; } // Check if I'm a member of this organization const memberships = await this.storage.get('organization_memberships') || []; const membership = memberships.find(m => m.organizationId === organizationId); if (!membership) { throw new Error('Not a member of this organization'); } // Get organization data from the owner's UserDO const namespace = this.findUserDONamespace(); const ownerDO = getUserDO(namespace, membership.ownerEmail); const ownerOrgData = await ownerDO.getOwnedOrganization(organizationId); return { organization: ownerOrgData.organization, members: ownerOrgData.members, isOwner: false }; } // Helper method for cross-UserDO access async getOwnedOrganization(organizationId) { const organization = await this.ownedOrganizations.findById(organizationId); if (!organization) throw new Error('Organization not found'); const members = await this.organizationMembers.where('organizationId', '==', organizationId).get(); return { organization, members }; } async addOrganizationMember(organizationId, email, role = 'member') { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Check if I own this organization const organization = await this.ownedOrganizations.findById(organizationId); if (!organization) { throw new Error('Organization not found or you do not own it'); } // Get target user ID from email const targetUserId = await hashEmailForId(email); // Check if member already exists const existingMember = await this.organizationMembers .where('organizationId', '==', organizationId) .where('email', '==', email.toLowerCase()) .first(); if (existingMember) { throw new Error('User is already a member of this organization'); } const member = { id: crypto.randomUUID(), organizationId, userId: targetUserId, email: email.toLowerCase(), role, createdAt: new Date().toISOString(), }; // Store member in my UserDO (I own the organization) await this.organizationMembers.create(member); // Add membership to the target user's UserDO try { const namespace = this.findUserDONamespace(); const targetUserDO = getUserDO(namespace, email.toLowerCase()); await targetUserDO.addMembership({ organizationId, organizationName: organization.name, ownerEmail: user.email, role, joinedAt: new Date().toISOString(), }); } catch (error) { console.error('Failed to add membership to target user:', error); console.log('Could not deliver membership immediately - will be available when user signs up'); } this.broadcast('organization:member_added', { organizationId, member }); return { member }; } // Helper method to add membership to a user's UserDO async addMembership(membership) { const memberships = await this.storage.get('organization_memberships') || []; // Check if membership already exists const existingIndex = memberships.findIndex(m => m.organizationId === membership.organizationId); if (existingIndex >= 0) { // Update existing membership memberships[existingIndex] = membership; } else { // Add new membership memberships.push(membership); } await this.storage.put('organization_memberships', memberships); } async removeOrganizationMember(organizationId, userId) { const user = await this.storage.get(AUTH_DATA_KEY); if (!user) throw new Error('User not found'); // Check if I own this organization const organization = await this.ownedOrganizations.findById(organizationId); if (!organization) { throw new Error('Organization not found or you do not own it'); } // Get the member to remove const member = await this.organizationMembers.where('organizationId', '==', organizationId).where('userId', '==', userId).first(); if (!member) { throw new Error('Member not found'); } // Remove member from my UserDO (I own the organization) await this.organizationMembers.delete(member.id); // Remove membership from the target user's UserDO try { const namespace = this.findUserDONamespace(); const targetUserDO = getUserDO(namespace, member.email); await targetUserDO.removeMembership(organizationId); } catch (error) { console.error('Failed to remove membership from target user:', error); // Don't fail the whole operation if this fails } this.broadcast('organization:member_removed', { organizationId, userId }); return { ok: true }; } // Helper method to remove membership from a user's UserDO async removeMembership(organizationId) { const memberships = await this.storage.get('organization_memberships') || []; const updatedMemberships = memberships.filter(m => m.organizationId !== organizationId); await this.storage.put('organization_memberships', updatedMemberships); } // Helper to find the correct UserDO namespace dynamically findUserDONamespace() { // Try USERDO first (default) if (this.env.USERDO) { return this.env.USERDO; } // Look for any UserDO-compatible namespace in the environment for (const [key, value] of Object.entries(this.env)) { if (value && typeof value === 'object' && 'get' in value && 'idFromName' in value) { return value; } } throw new Error('No UserDO namespace found in environment'); } table(name, schema, options) { return this.database.table(name, schema, options); } get db() { return this.database.raw; } getCurrentUserId() { return this.state.id.toString(); } // WebSocket connection handling using Hibernation API async fetch(request) { const url = new URL(request.url); // Handle WebSocket upgrades directly in the UserDO if (request.headers.get('upgrade') === 'websocket') { const webSocketPair = new WebSocketPair(); const [client, server] = Object.values(webSocketPair); // Use hibernation API - this makes the WebSocket hibernatable this.ctx.acceptWebSocket(server); console.log('🔌 WebSocket accepted by UserDO with hibernation'); // Send welcome message server.send(JSON.stringify({ event: 'connected', message: 'WebSocket connected to UserDO!', timestamp: Date.now() })); return new Response(null, { status: 101, webSocket: client, }); } // Handle other requests normally return new Response('Not Found', { status: 404 }); } // WebSocket message handler (called by runtime when hibernated) async webSocketMessage(ws, message) { try { const data = typeof message === 'string' ? message : new TextDecoder().decode(message); const parsed = JSON.parse(data); console.log('📨 UserDO WebSocket message received:', parsed); // Echo back ws.send(JSON.stringify({ event: 'echo', original: parsed, message: 'Message received by UserDO', timestamp: Date.now() })); } catch (error) { console.error('WebSocket message error:', error); } } // WebSocket close handler (called by runtime when hibernated) async webSocketClose(ws, code, reason, wasClean) { console.log('🔌 UserDO WebSocket closed:', { code, reason, wasClean }); } // Broadcast to all connected WebSocket clients using hibernation API broadcast(event, data) { const message = JSON.stringify({ event, data, timestamp: Date.now() }); // Use hibernation API to get all connected WebSockets const webSockets = this.ctx.getWebSockets(); console.log(`📡 UserDO Broadcasting to ${webSockets.length} WebSocket clients:`, { event, data }); for (const ws of webSockets) { try { ws.send(message); } catch (error) { console.error('Broadcast error:', error); // WebSocket will be automatically cleaned up by runtime } } } } export default {};