import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User, BoardAccessToken, GlobalAdmin } from './types'; // Generate a UUID v4 function generateUUID(): string { return crypto.randomUUID(); } /** * Check if a user is a global admin by their email * Global admins have admin access to ALL boards */ export async function isGlobalAdmin(db: D1Database, userId: string): Promise { // Get user's email const user = await db.prepare( 'SELECT email FROM users WHERE id = ?' ).bind(userId).first<{ email: string }>(); if (!user?.email) { return false; } // Check if email is in global_admins table const admin = await db.prepare( 'SELECT email FROM global_admins WHERE email = ?' ).bind(user.email).first(); return !!admin; } /** * Check if an email is a global admin (direct check without user lookup) */ export async function isEmailGlobalAdmin(db: D1Database, email: string): Promise { const admin = await db.prepare( 'SELECT email FROM global_admins WHERE email = ?' ).bind(email).first(); return !!admin; } /** * Get a user's effective permission for a board * * NEW PERMISSION MODEL (Dec 2024): * - Everyone (including anonymous) can EDIT by default * - Boards can be marked as "protected" - only listed editors can edit protected boards * - Global admins have admin access to ALL boards * * Priority: * 1. Access token from share link (overrides all) * 2. Global admin status (returns 'admin') * 3. Board owner (returns 'admin') * 4. If board is NOT protected → everyone gets 'edit' * 5. If board IS protected → check explicit permissions, default to 'view' * * @param accessToken - Optional access token from share link (grants specific permission) */ export async function getEffectivePermission( db: D1Database, boardId: string, userId: string | null, accessToken?: string | null ): Promise { // Check if board exists const board = await db.prepare( 'SELECT * FROM boards WHERE id = ?' ).bind(boardId).first(); // 1. If an access token is provided, validate it and use its permission level if (accessToken) { const tokenPermission = await validateAccessToken(db, boardId, accessToken); if (tokenPermission) { return { permission: tokenPermission, isOwner: false, boardExists: !!board, grantedByToken: true }; } } // 2. Check if user is a global admin (admin on ALL boards) if (userId) { const globalAdmin = await isGlobalAdmin(db, userId); if (globalAdmin) { console.log('🔐 User is global admin, granting admin access'); return { permission: 'admin', isOwner: false, boardExists: !!board, isGlobalAdmin: true }; } } // Board doesn't exist in permissions DB // NEW: Everyone can edit by default (board will be created on first edit) if (!board) { return { permission: 'edit', isOwner: false, boardExists: false }; } // 3. Check if user is the board owner (always admin) if (userId && board.owner_id === userId) { return { permission: 'admin', isOwner: true, boardExists: true }; } // 4. If board is NOT protected, everyone can edit (NEW DEFAULT) if (!board.is_protected) { return { permission: 'edit', isOwner: false, boardExists: true, isProtected: false }; } // 5. Board IS protected - check for explicit permission if (userId) { const explicitPerm = await db.prepare( 'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?' ).bind(boardId, userId).first(); if (explicitPerm) { // User has been granted specific permission on this protected board return { permission: explicitPerm.permission, isOwner: false, boardExists: true, isProtected: true, isExplicitPermission: true }; } } // 6. Protected board, no explicit permission → view only return { permission: 'view', isOwner: false, boardExists: true, isProtected: true }; } /** * Create a board and assign owner * Called when a new board is first accessed by an authenticated user */ export async function createBoard( db: D1Database, boardId: string, ownerId: string, name?: string ): Promise { const id = boardId; await db.prepare(` INSERT INTO boards (id, owner_id, name, default_permission, is_public) VALUES (?, ?, ?, 'edit', 1) ON CONFLICT(id) DO NOTHING `).bind(id, ownerId, name || null).run(); const board = await db.prepare( 'SELECT * FROM boards WHERE id = ?' ).bind(id).first(); if (!board) { throw new Error('Failed to create board'); } return board; } /** * Ensure a board exists, creating it if necessary * Called on first edit by authenticated user */ export async function ensureBoardExists( db: D1Database, boardId: string, userId: string ): Promise { let board = await db.prepare( 'SELECT * FROM boards WHERE id = ?' ).bind(boardId).first(); if (!board) { // Create the board with this user as owner board = await createBoard(db, boardId, userId); } return board; } /** * GET /boards/:boardId/permission * Get current user's permission for a board * Query params: ?token= - optional access token from share link */ export async function handleGetPermission( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { // No database - default to view for anonymous (secure by default) console.log('🔐 Permission check: No database configured'); return new Response(JSON.stringify({ permission: 'view', isOwner: false, boardExists: false, message: 'Permission system not configured' }), { headers: { 'Content-Type': 'application/json' }, }); } // Get user ID from public key if provided let userId: string | null = null; const publicKey = request.headers.get('X-CryptID-PublicKey'); console.log('🔐 Permission check for board:', boardId, { publicKeyReceived: publicKey ? `${publicKey.substring(0, 20)}...` : null }); if (publicKey) { const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (deviceKey) { userId = deviceKey.user_id; console.log('🔐 Found user ID for public key:', userId); } else { console.log('🔐 No user found for public key'); } } // Get access token from query params if provided const url = new URL(request.url); const accessToken = url.searchParams.get('token'); const result = await getEffectivePermission(db, boardId, userId, accessToken); console.log('🔐 Permission result:', result); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Get permission error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * GET /boards/:boardId/permissions * List all permissions for a board (admin only) */ export async function handleListPermissions( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } // Get all permissions with user info const permissions = await db.prepare(` SELECT bp.*, u.cryptid_username, u.email FROM board_permissions bp JOIN users u ON bp.user_id = u.id WHERE bp.board_id = ? ORDER BY bp.granted_at DESC `).bind(boardId).all(); // Get board info const board = await db.prepare( 'SELECT * FROM boards WHERE id = ?' ).bind(boardId).first(); // Get owner info if exists let owner = null; if (board?.owner_id) { owner = await db.prepare( 'SELECT id, cryptid_username, email FROM users WHERE id = ?' ).bind(board.owner_id).first<{ id: string; cryptid_username: string; email: string }>(); } return new Response(JSON.stringify({ board: board ? { id: board.id, name: board.name, defaultPermission: board.default_permission, isPublic: board.is_public === 1, createdAt: board.created_at } : null, owner, permissions: permissions.results || [] }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('List permissions error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * POST /boards/:boardId/permissions * Grant permission to a user (admin only) * Body: { userId, permission, username? } * * Note: This endpoint allows granting 'admin' permission because it requires * specifying a user by ID or CryptID username. This is the ONLY way to grant * admin access - share links (access tokens) can only grant 'view' or 'edit'. */ export async function handleGrantPermission( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const adminDeviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!adminDeviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const body = await request.json() as { userId?: string; username?: string; permission: PermissionLevel; }; const { userId, username, permission } = body; if (!permission || !['view', 'edit', 'admin'].includes(permission)) { return new Response(JSON.stringify({ error: 'Invalid permission level' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Find target user let targetUserId = userId; if (!targetUserId && username) { const user = await db.prepare( 'SELECT id FROM users WHERE cryptid_username = ?' ).bind(username).first<{ id: string }>(); if (!user) { return new Response(JSON.stringify({ error: 'User not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } targetUserId = user.id; } if (!targetUserId) { return new Response(JSON.stringify({ error: 'userId or username required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Ensure board exists await ensureBoardExists(db, boardId, adminDeviceKey.user_id); // Upsert permission await db.prepare(` INSERT INTO board_permissions (id, board_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?) ON CONFLICT(board_id, user_id) DO UPDATE SET permission = excluded.permission, granted_by = excluded.granted_by, granted_at = datetime('now') `).bind(generateUUID(), boardId, targetUserId, permission, adminDeviceKey.user_id).run(); return new Response(JSON.stringify({ success: true, message: `Permission '${permission}' granted to user` }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Grant permission error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * DELETE /boards/:boardId/permissions/:userId * Revoke a user's permission (admin only) */ export async function handleRevokePermission( boardId: string, targetUserId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const adminDeviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!adminDeviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, adminDeviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } // Can't revoke from board owner const board = await db.prepare( 'SELECT owner_id FROM boards WHERE id = ?' ).bind(boardId).first<{ owner_id: string }>(); if (board?.owner_id === targetUserId) { return new Response(JSON.stringify({ error: 'Cannot revoke permission from board owner' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Delete permission await db.prepare( 'DELETE FROM board_permissions WHERE board_id = ? AND user_id = ?' ).bind(boardId, targetUserId).run(); return new Response(JSON.stringify({ success: true, message: 'Permission revoked' }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Revoke permission error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * PATCH /boards/:boardId * Update board settings (admin only) * Body: { name?, defaultPermission?, isPublic?, isProtected? } */ export async function handleUpdateBoard( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const body = await request.json() as { name?: string; defaultPermission?: 'view' | 'edit'; isPublic?: boolean; isProtected?: boolean; }; const updates: string[] = []; const values: any[] = []; if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name); } if (body.defaultPermission !== undefined) { if (!['view', 'edit'].includes(body.defaultPermission)) { return new Response(JSON.stringify({ error: 'Invalid default permission' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } updates.push('default_permission = ?'); values.push(body.defaultPermission); } if (body.isPublic !== undefined) { updates.push('is_public = ?'); values.push(body.isPublic ? 1 : 0); } if (body.isProtected !== undefined) { updates.push('is_protected = ?'); values.push(body.isProtected ? 1 : 0); console.log(`🔒 Board ${boardId} protection set to: ${body.isProtected}`); } if (updates.length === 0) { return new Response(JSON.stringify({ error: 'No updates provided' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } updates.push("updated_at = datetime('now')"); values.push(boardId); await db.prepare(` UPDATE boards SET ${updates.join(', ')} WHERE id = ? `).bind(...values).run(); const updatedBoard = await db.prepare( 'SELECT * FROM boards WHERE id = ?' ).bind(boardId).first(); return new Response(JSON.stringify({ success: true, board: updatedBoard ? { id: updatedBoard.id, name: updatedBoard.name, defaultPermission: updatedBoard.default_permission, isPublic: updatedBoard.is_public === 1, isProtected: updatedBoard.is_protected === 1, updatedAt: updatedBoard.updated_at } : null }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Update board error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } // ============================================================================= // Access Token Functions // ============================================================================= /** * Generate a cryptographically secure random token */ function generateAccessToken(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Validate an access token and return the permission level if valid */ export async function validateAccessToken( db: D1Database, boardId: string, token: string ): Promise { const accessToken = await db.prepare(` SELECT * FROM board_access_tokens WHERE board_id = ? AND token = ? AND is_active = 1 `).bind(boardId, token).first(); if (!accessToken) { return null; } // Check expiration if (accessToken.expires_at) { const expiresAt = new Date(accessToken.expires_at); if (expiresAt < new Date()) { return null; } } // Check max uses if (accessToken.max_uses !== null && accessToken.use_count >= accessToken.max_uses) { return null; } // Increment use count await db.prepare(` UPDATE board_access_tokens SET use_count = use_count + 1 WHERE id = ? `).bind(accessToken.id).run(); return accessToken.permission; } /** * POST /boards/:boardId/access-tokens * Create a new access token (admin only) * Body: { permission, label?, expiresIn?, maxUses? } */ export async function handleCreateAccessToken( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const body = await request.json() as { permission: PermissionLevel; label?: string; expiresIn?: number; // seconds from now maxUses?: number; }; // Only allow 'view' and 'edit' permissions for access tokens // Admin permission must be granted directly by username/email through handleGrantPermission if (!body.permission || !['view', 'edit'].includes(body.permission)) { return new Response(JSON.stringify({ error: 'Invalid permission level. Share links can only grant view or edit access. Use direct permission grants for admin access.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Ensure board exists await ensureBoardExists(db, boardId, deviceKey.user_id); const token = generateAccessToken(); const id = generateUUID(); // Calculate expiration let expiresAt: string | null = null; if (body.expiresIn) { const expDate = new Date(Date.now() + body.expiresIn * 1000); expiresAt = expDate.toISOString(); } await db.prepare(` INSERT INTO board_access_tokens (id, board_id, token, permission, created_by, expires_at, max_uses, label, use_count, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 1) `).bind( id, boardId, token, body.permission, deviceKey.user_id, expiresAt, body.maxUses || null, body.label || null ).run(); return new Response(JSON.stringify({ success: true, token, id, permission: body.permission, expiresAt, maxUses: body.maxUses || null, label: body.label || null }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Create access token error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * GET /boards/:boardId/access-tokens * List all access tokens for a board (admin only) */ export async function handleListAccessTokens( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const tokens = await db.prepare(` SELECT id, board_id, permission, created_at, expires_at, max_uses, use_count, is_active, label FROM board_access_tokens WHERE board_id = ? ORDER BY created_at DESC `).bind(boardId).all>(); return new Response(JSON.stringify({ tokens: tokens.results || [] }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('List access tokens error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * DELETE /boards/:boardId/access-tokens/:tokenId * Revoke an access token (admin only) */ export async function handleRevokeAccessToken( boardId: string, tokenId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } // Deactivate the token (soft delete) await db.prepare(` UPDATE board_access_tokens SET is_active = 0 WHERE id = ? AND board_id = ? `).bind(tokenId, boardId).run(); return new Response(JSON.stringify({ success: true, message: 'Access token revoked' }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Revoke access token error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } // ============================================================================= // Global Admin & Protected Board Functions // ============================================================================= /** * GET /auth/global-admin-status * Check if the current user is a global admin */ export async function handleGetGlobalAdminStatus( request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ isGlobalAdmin: false }), { headers: { 'Content-Type': 'application/json' }, }); } const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ isGlobalAdmin: false }), { headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ isGlobalAdmin: false }), { headers: { 'Content-Type': 'application/json' }, }); } const isAdmin = await isGlobalAdmin(db, deviceKey.user_id); return new Response(JSON.stringify({ isGlobalAdmin: isAdmin }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Global admin status check error:', error); return new Response(JSON.stringify({ isGlobalAdmin: false }), { headers: { 'Content-Type': 'application/json' }, }); } } /** * POST /admin/request * Request global admin access (sends email to existing global admin) * Body: { reason?: string } */ export async function handleRequestAdminAccess( request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Must be authenticated const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Get user info const user = await db.prepare( 'SELECT cryptid_username, email FROM users WHERE id = ?' ).bind(deviceKey.user_id).first<{ cryptid_username: string; email: string }>(); if (!user) { return new Response(JSON.stringify({ error: 'User not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Check if already a global admin if (await isGlobalAdmin(db, deviceKey.user_id)) { return new Response(JSON.stringify({ success: false, message: 'You are already a global admin' }), { headers: { 'Content-Type': 'application/json' }, }); } const body = await request.json().catch(() => ({})) as { reason?: string }; // Send email to global admin (jeffemmett@gmail.com) if (env.RESEND_API_KEY) { const emailFrom = env.CRYPTID_EMAIL_FROM || 'Canvas '; const emailResponse = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: emailFrom, to: 'jeffemmett@gmail.com', subject: `Canvas Admin Request from ${user.cryptid_username}`, html: `

Admin Access Request

User: ${user.cryptid_username}

Email: ${user.email || 'Not provided'}

User ID: ${deviceKey.user_id}

${body.reason ? `

Reason: ${body.reason}

` : ''}

To grant admin access, add their email to the global_admins table in D1.

`, }), }); if (!emailResponse.ok) { console.error('Failed to send admin request email:', await emailResponse.text()); } } return new Response(JSON.stringify({ success: true, message: 'Admin access request sent. You will be notified when approved.' }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Request admin access error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * GET /boards/:boardId/info * Get board info including protection status (public endpoint) */ export async function handleGetBoardInfo( boardId: string, _request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ board: null, isProtected: false }), { headers: { 'Content-Type': 'application/json' }, }); } const board = await db.prepare( 'SELECT id, name, is_protected, owner_id FROM boards WHERE id = ?' ).bind(boardId).first<{ id: string; name: string | null; is_protected: number; owner_id: string | null }>(); if (!board) { return new Response(JSON.stringify({ board: null, isProtected: false }), { headers: { 'Content-Type': 'application/json' }, }); } // Get owner username if exists let ownerUsername: string | null = null; if (board.owner_id) { const owner = await db.prepare( 'SELECT cryptid_username FROM users WHERE id = ?' ).bind(board.owner_id).first<{ cryptid_username: string }>(); ownerUsername = owner?.cryptid_username || null; } return new Response(JSON.stringify({ board: { id: board.id, name: board.name, isProtected: board.is_protected === 1, ownerUsername, } }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Get board info error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * GET /boards/:boardId/editors * List users with edit access on a protected board (admin only) */ export async function handleListEditors( boardId: string, request: Request, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Authenticate user const publicKey = request.headers.get('X-CryptID-PublicKey'); if (!publicKey) { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const deviceKey = await db.prepare( 'SELECT user_id FROM device_keys WHERE public_key = ?' ).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Invalid credentials' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Check if user is admin const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id); if (permCheck.permission !== 'admin') { return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } // Get all users with edit or admin permission const editors = await db.prepare(` SELECT bp.user_id, bp.permission, bp.granted_at, u.cryptid_username, u.email FROM board_permissions bp JOIN users u ON bp.user_id = u.id WHERE bp.board_id = ? AND bp.permission IN ('edit', 'admin') ORDER BY bp.granted_at DESC `).bind(boardId).all<{ user_id: string; permission: string; granted_at: string; cryptid_username: string; email: string; }>(); return new Response(JSON.stringify({ editors: (editors.results || []).map(e => ({ userId: e.user_id, username: e.cryptid_username, email: e.email, permission: e.permission, grantedAt: e.granted_at, })) }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('List editors error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } }