userdo
Version:
A Durable Object base class for building applications on Cloudflare Workers.
426 lines (425 loc) • 17.1 kB
JavaScript
import { Hono } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { cors } from 'hono/cors';
import { createAuthMiddleware } from './authMiddleware.js';
import { UserDO } from './UserDO.js';
import { SignupRequestSchema, LoginRequestSchema, PasswordResetRequestSchema, PasswordResetConfirmSchema, SetDataRequestSchema, } from './worker-types.js';
// --- UTILITIES ---
const isRequestSecure = (c) => new URL(c.req.url).protocol === 'https:';
const setAuthCookies = (c, token, refreshToken) => {
const cookieOptions = {
httpOnly: true,
secure: isRequestSecure(c),
path: '/',
sameSite: 'Lax'
};
setCookie(c, 'token', token, cookieOptions);
setCookie(c, 'refreshToken', refreshToken, cookieOptions);
};
const clearAuthCookies = (c) => {
deleteCookie(c, 'token');
deleteCookie(c, 'refreshToken');
};
const parseBody = async (c, schema) => {
const contentType = c.req.header('content-type') || '';
if (contentType.includes('application/json')) {
return schema.parse(await c.req.json());
}
else {
const formData = await c.req.formData();
const entries = {};
formData.forEach((value, key) => {
entries[key] = value;
});
return schema.parse(entries);
}
};
const handleError = (e, defaultMessage) => {
const errorResponse = { error: e.message || defaultMessage };
return { errorResponse, status: 400 };
};
const requireAuth = (c) => {
const user = c.get('user');
if (!user) {
throw new Error('Not authenticated');
}
return user;
};
// --- ROUTE FACTORY ---
function createRoutes(getUserDO) {
const routes = new Hono();
// CORS middleware (must come before auth middleware)
routes.use('/*', cors({
origin: (origin) => origin, // Allow all origins in development
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
}));
// Auth middleware
routes.use('/*', createAuthMiddleware(getUserDO));
// --- API ENDPOINTS ---
routes.post('/api/signup', async (c) => {
try {
const { email, password } = await parseBody(c, SignupRequestSchema);
const userDO = getUserDO(c, email.toLowerCase());
const { user, token, refreshToken } = await userDO.signup({ email: email.toLowerCase(), password });
setAuthCookies(c, token, refreshToken);
const response = { user, token, refreshToken };
return c.json(response);
}
catch (e) {
const { errorResponse, status } = handleError(e, "Signup failed");
return c.json(errorResponse, status);
}
});
routes.post('/api/login', async (c) => {
try {
const { email, password } = await parseBody(c, LoginRequestSchema);
const userDO = getUserDO(c, email.toLowerCase());
const { user, token, refreshToken } = await userDO.login({ email: email.toLowerCase(), password });
setAuthCookies(c, token, refreshToken);
const response = { user, token, refreshToken };
return c.json(response);
}
catch (e) {
const { errorResponse, status } = handleError(e, "Login failed");
return c.json(errorResponse, status);
}
});
routes.post('/api/logout', async (c) => {
try {
const token = getCookie(c, 'token') || '';
const tokenParts = token.split('.');
if (tokenParts.length === 3 && tokenParts[1]) {
const payload = JSON.parse(atob(tokenParts[1]));
const email = payload.email?.toLowerCase();
if (email) {
const userDO = getUserDO(c, email);
await userDO.logout();
}
}
}
catch (e) {
console.error('Logout error', e);
}
clearAuthCookies(c);
const response = { ok: true };
return c.json(response);
});
routes.post('/api/password-reset/request', async (c) => {
try {
const { email } = await parseBody(c, PasswordResetRequestSchema);
const userDO = getUserDO(c, email.toLowerCase());
const { resetToken } = await userDO.generatePasswordResetToken();
return c.json({
ok: true,
message: "Reset token generated",
resetToken // Remove this in production!
});
}
catch (e) {
const { errorResponse, status } = handleError(e, "Password reset request failed");
return c.json(errorResponse, status);
}
});
routes.post('/api/password-reset/confirm', async (c) => {
try {
const { resetToken, newPassword } = await parseBody(c, PasswordResetConfirmSchema);
const tokenParts = resetToken.split('.');
if (tokenParts.length !== 3)
throw new Error('Invalid token format');
const payload = JSON.parse(atob(tokenParts[1]));
const email = payload.email?.toLowerCase();
if (!email)
throw new Error('Invalid token');
const userDO = getUserDO(c, email);
await userDO.resetPasswordWithToken({ resetToken, newPassword });
return c.json({ ok: true, message: "Password reset successful" });
}
catch (e) {
const { errorResponse, status } = handleError(e, "Password reset failed");
return c.json(errorResponse, status);
}
});
routes.get('/api/me', async (c) => {
try {
const user = requireAuth(c);
return c.json({ user });
}
catch (e) {
const { errorResponse } = handleError(e, "Not authenticated");
return c.json(errorResponse, 401);
}
});
// --- FORM ENDPOINTS ---
const handleFormAuth = async (c, action) => {
try {
const formData = await c.req.formData();
const email = formData.get('email')?.toLowerCase();
const password = formData.get('password');
if (!email || !password) {
return c.json({ error: "Missing fields" }, 400);
}
const userDO = getUserDO(c, email);
const { user, token, refreshToken } = await userDO[action]({ email, password });
setAuthCookies(c, token, refreshToken);
return c.redirect('/');
}
catch (e) {
return c.json({ error: e.message || `${action} error` }, 400);
}
};
routes.post('/signup', (c) => handleFormAuth(c, 'signup'));
routes.post('/login', (c) => handleFormAuth(c, 'login'));
// Shared logout handler
const handleLogout = async (c) => {
try {
const token = getCookie(c, 'token') || '';
const tokenParts = token.split('.');
if (tokenParts.length === 3 && tokenParts[1]) {
const payload = JSON.parse(atob(tokenParts[1]));
const email = payload.email?.toLowerCase();
if (email) {
const userDO = getUserDO(c, email);
await userDO.logout();
}
}
}
catch (e) {
console.error('Logout error', e);
}
clearAuthCookies(c);
return c.redirect('/');
};
routes.get('/logout', handleLogout);
routes.post('/logout', handleLogout);
// --- DATA ENDPOINTS ---
routes.get("/data", async (c) => {
try {
const user = requireAuth(c);
const userDO = getUserDO(c, user.email);
const result = await userDO.get('data');
const response = { ok: true, data: result };
return c.json(response);
}
catch (e) {
const { errorResponse } = handleError(e, "Unauthorized");
return c.json(errorResponse, 401);
}
});
routes.post("/data", async (c) => {
try {
const user = requireAuth(c);
const { key, value } = await parseBody(c, SetDataRequestSchema);
const userDO = getUserDO(c, user.email);
const result = await userDO.set(key, value);
if (!result.ok) {
throw new Error('Failed to set data');
}
// Broadcast WebSocket notification for data changes
console.log(`🔥 Data changed for ${user.email}: ${key} = ${JSON.stringify(value)}`);
broadcastToUser(user.email, {
event: `kv:${key}`,
data: { key, value },
timestamp: Date.now()
}, 'USERDO', c.env);
const response = { ok: true, data: { key, value } };
return c.json(response);
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Invalid data format');
return c.json(errorResponse, status);
}
});
routes.get('/protected/profile', (c) => {
try {
const user = requireAuth(c);
return c.json({ ok: true, user });
}
catch (e) {
const { errorResponse } = handleError(e, "Unauthorized");
return c.json(errorResponse, 401);
}
});
// --- ORGANIZATION ENDPOINTS ---
routes.post('/api/organizations', async (c) => {
try {
const user = requireAuth(c);
const { name } = await parseBody(c, { parse: (data) => ({ name: data.name }) });
if (!name) {
throw new Error('Organization name is required');
}
const userDO = getUserDO(c, user.email);
const result = await userDO.createOrganization(name);
const contentType = c.req.header('content-type') || '';
if (contentType.includes('application/json')) {
return c.json(result);
}
else {
return c.redirect('/organizations');
}
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Failed to create organization');
return c.json(errorResponse, status);
}
});
routes.get('/api/organizations', async (c) => {
try {
const user = requireAuth(c);
const userDO = getUserDO(c, user.email);
const result = await userDO.getOrganizations();
return c.json(result);
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Failed to get organizations');
return c.json(errorResponse, status);
}
});
routes.get('/api/organizations/:id', async (c) => {
try {
const user = requireAuth(c);
const organizationId = c.req.param('id');
if (!organizationId) {
throw new Error('Organization ID is required');
}
const userDO = getUserDO(c, user.email);
const result = await userDO.getOrganization(organizationId);
return c.json(result);
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Failed to get organization');
return c.json(errorResponse, status);
}
});
routes.post('/api/organizations/:id/members', async (c) => {
try {
const user = requireAuth(c);
const organizationId = c.req.param('id');
if (!organizationId) {
throw new Error('Organization ID is required');
}
const { email, role = 'member' } = await parseBody(c, {
parse: (data) => ({ email: data.email, role: data.role || 'member' })
});
if (!email) {
throw new Error('Email is required');
}
const userDO = getUserDO(c, user.email);
const result = await userDO.addOrganizationMember(organizationId, email.toLowerCase(), role);
const contentType = c.req.header('content-type') || '';
if (contentType.includes('application/json')) {
return c.json(result);
}
else {
return c.redirect(`/organizations/${organizationId}`);
}
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Failed to add member');
return c.json(errorResponse, status);
}
});
routes.delete('/api/organizations/:id/members/:userId', async (c) => {
try {
const user = requireAuth(c);
const organizationId = c.req.param('id');
const userId = c.req.param('userId');
if (!organizationId || !userId) {
throw new Error('Organization ID and User ID are required');
}
const userDO = getUserDO(c, user.email);
const result = await userDO.removeOrganizationMember(organizationId, userId);
return c.json(result);
}
catch (e) {
const { errorResponse, status } = handleError(e, 'Failed to remove member');
return c.json(errorResponse, status);
}
});
routes.get('/api/docs', async (c) => {
return c.json({
name: 'UserDO',
version: '0.1.37',
status: 'ready',
endpoints: {
auth: ['/api/signup', '/api/login', '/api/logout', '/api/me'],
data: ['/data'],
organizations: ['/api/organizations', '/api/organizations/:id', '/api/organizations/:id/members'],
passwordReset: ['/api/password-reset/request', '/api/password-reset/confirm']
},
docs: 'https://github.com/acoyfellow/userdo'
});
});
return routes;
}
// --- MAIN EXPORTS ---
export function getUserDOFromContext(c, email, bindingName = 'USERDO') {
const binding = c.env[bindingName];
if (!binding) {
throw new Error(`Durable Object binding '${bindingName}' not found. Make sure it's configured in wrangler.jsonc`);
}
const userDOID = binding.idFromName(email);
return binding.get(userDOID);
}
export function createUserDOWorker(bindingName = 'USERDO') {
return createRoutes((c, email) => getUserDOFromContext(c, email, bindingName));
}
export function broadcastToUser(email, message, bindingName = 'USERDO', env) {
const binding = env[bindingName];
if (!binding) {
console.error(`Durable Object binding '${bindingName}' not found`);
return;
}
const userDOID = binding.idFromName(email);
const userDO = binding.get(userDOID);
userDO.broadcast(message.event, message.data).catch((error) => {
console.error('Failed to broadcast to UserDO:', error);
});
}
export function createWebSocketHandler(bindingName = 'USERDO') {
return {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === '/api/ws' && request.headers.get('upgrade') === 'websocket') {
console.log('🔌 WebSocket upgrade request received');
const cookieHeader = request.headers.get('cookie') || '';
const cookies = Object.fromEntries(cookieHeader.split(';')
.filter(c => c.includes('='))
.map(c => c.trim().split('=')));
const token = cookies.token || '';
if (!token) {
console.log('❌ No auth token for WebSocket');
return new Response('Unauthorized', { status: 401 });
}
try {
const parts = token.split('.');
if (parts.length !== 3)
throw new Error('Invalid token format');
const payload = JSON.parse(atob(parts[1]));
const email = payload.email?.toLowerCase();
if (!email)
throw new Error('No email in token');
console.log(`🔌 WebSocket auth successful for: ${email}`);
const binding = env[bindingName];
if (!binding) {
throw new Error(`Durable Object binding '${bindingName}' not found`);
}
const userDOID = binding.idFromName(email);
const userDO = binding.get(userDOID);
return userDO.fetch(request);
}
catch (error) {
console.log('❌ WebSocket auth failed:', error);
return new Response('Unauthorized', { status: 401 });
}
}
return new Response('Not Found', { status: 404 });
}
};
}
// Create main app and export
const app = createRoutes(getUserDOFromContext);
export { UserDO };
export { app as userDOWorker };
export default app;