From c00106e2b7990d771d12268e5557aa04a8d7dcab Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 5 Dec 2025 22:27:12 -0800 Subject: [PATCH] feat: implement user permissions system (view/edit/admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of user permissions feature: - Add board permissions schema to D1 database - boards table with owner, default_permission, is_public - board_permissions table for per-user permissions - Add permission types (PermissionLevel) to worker and client - Implement permission API handlers in worker/boardPermissions.ts - GET /boards/:boardId/permission - check user's permission - GET /boards/:boardId/permissions - list all (admin only) - POST /boards/:boardId/permissions - grant permission (admin) - DELETE /boards/:boardId/permissions/:userId - revoke (admin) - PATCH /boards/:boardId - update board settings (admin) - Update AuthContext with permission fetching and caching - fetchBoardPermission() - fetch and cache permission for a board - canEdit() - check if user can edit current board - isAdmin() - check if user is admin for current board - Create AnonymousViewerBanner component with CryptID signup prompt - Add CSS styles for anonymous viewer banner - Fix automerge sync manager to flush saves on peer disconnect Permission levels: - view: Read-only, cannot create/edit/delete shapes - edit: Can modify board contents - admin: Full access + permission management Next steps: - Integrate with Board component for read-only mode - Wire up permission checking in Automerge sync - Add permission management UI for admins 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...User-Permissions-View-Edit-Admin-Levels.md | 79 +++ src/components/auth/AnonymousViewerBanner.tsx | 169 +++++ src/context/AuthContext.tsx | 93 ++- src/css/anonymous-banner.css | 323 ++++++++++ src/lib/auth/types.ts | 12 + worker/AutomergeDurableObject.ts | 6 +- worker/automerge-sync-manager.ts | 10 +- worker/boardPermissions.ts | 581 ++++++++++++++++++ worker/schema.sql | 95 +++ worker/types.ts | 47 ++ 10 files changed, 1408 insertions(+), 7 deletions(-) create mode 100644 backlog/tasks/task-042 - User-Permissions-View-Edit-Admin-Levels.md create mode 100644 src/components/auth/AnonymousViewerBanner.tsx create mode 100644 src/css/anonymous-banner.css create mode 100644 worker/boardPermissions.ts diff --git a/backlog/tasks/task-042 - User-Permissions-View-Edit-Admin-Levels.md b/backlog/tasks/task-042 - User-Permissions-View-Edit-Admin-Levels.md new file mode 100644 index 0000000..979d398 --- /dev/null +++ b/backlog/tasks/task-042 - User-Permissions-View-Edit-Admin-Levels.md @@ -0,0 +1,79 @@ +--- +id: task-042 +title: User Permissions - View, Edit, Admin Levels +status: In Progress +assignee: [@claude] +created_date: '2025-12-05 14:00' +updated_date: '2025-12-05 14:00' +labels: + - feature + - auth + - permissions + - cryptid + - security +dependencies: + - task-018 +priority: high +--- + +## Description + + +Implement a three-tier permission system for canvas boards: + +**Permission Levels:** +1. **View** - Can see board contents, cannot edit. Default for anonymous/unauthenticated users. +2. **Edit** - Can see and modify board contents. Requires CryptID authentication. +3. **Admin** - Full access + can manage board settings and user permissions. Board owner by default. + +**Key Features:** +- Anonymous users can view any shared board but cannot edit +- Creating a CryptID (username only, no password) grants edit access +- CryptID uses WebCrypto API for browser-based cryptographic keys (W3C standard) +- Session state encrypted and stored offline for authenticated users +- Admins can invite users with specific permission levels + +**Anonymous User Banner:** +Display a banner for unauthenticated users: +> "If you want to edit this board, just sign in by creating a username as your CryptID - no password required! Your CryptID is secured with encrypted keys, right in your browser, by a W3C standard algorithm. As a bonus, your session will be stored for offline access, encrypted in your browser storage by the same key, allowing you to use it securely any time you like, with full data portability." + +**Technical Foundation:** +- Builds on existing CryptID WebCrypto authentication (`auth-webcrypto` branch) +- Extends D1 database schema for board-level permissions +- Read-only mode in tldraw editor for view-only users + + +## Acceptance Criteria + +- [ ] #1 Anonymous users can view any shared board content +- [ ] #2 Anonymous users cannot create, edit, or delete shapes +- [ ] #3 Anonymous users see a dismissible banner prompting CryptID sign-up +- [ ] #4 Creating a CryptID grants immediate edit access to current board +- [ ] #5 Board creator automatically becomes admin +- [ ] #6 Admins can view and manage board permissions +- [ ] #7 Permission levels enforced on both client and server (worker) +- [ ] #8 Authenticated user sessions stored encrypted in browser storage +- [ ] #9 Read-only toolbar/UI state for view-only users +- [ ] #10 Permission state syncs correctly across devices via CryptID + + +## Implementation Notes + + +**Branch:** `feature/user-permissions` + +**Completed:** +- [x] Database schema for boards and board_permissions tables +- [x] Permission types (PermissionLevel) in worker and client +- [x] Permission API handlers (boardPermissions.ts) +- [x] AuthContext updated with permission fetching/caching +- [x] AnonymousViewerBanner component with CryptID signup + +**In Progress:** +- [ ] Board component read-only mode integration +- [ ] Automerge sync permission checking + +**Dependencies:** +- `task-018` - D1 database creation (blocking for production) +- `auth-webcrypto` branch - WebCrypto authentication (merged) + diff --git a/src/components/auth/AnonymousViewerBanner.tsx b/src/components/auth/AnonymousViewerBanner.tsx new file mode 100644 index 0000000..a01d233 --- /dev/null +++ b/src/components/auth/AnonymousViewerBanner.tsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import CryptID from './CryptID'; +import '../../css/anonymous-banner.css'; + +interface AnonymousViewerBannerProps { + /** Callback when user successfully signs up/logs in */ + onAuthenticated?: () => void; + /** Whether the banner was triggered by an edit attempt */ + triggeredByEdit?: boolean; +} + +/** + * Banner shown to anonymous (unauthenticated) users viewing a board. + * Explains CryptID and provides a smooth sign-up flow. + */ +const AnonymousViewerBanner: React.FC = ({ + onAuthenticated, + triggeredByEdit = false +}) => { + const { session } = useAuth(); + const [isDismissed, setIsDismissed] = useState(false); + const [showSignUp, setShowSignUp] = useState(false); + const [isExpanded, setIsExpanded] = useState(triggeredByEdit); + + // Check if banner was previously dismissed this session + useEffect(() => { + const dismissed = sessionStorage.getItem('anonymousBannerDismissed'); + if (dismissed && !triggeredByEdit) { + setIsDismissed(true); + } + }, [triggeredByEdit]); + + // If user is authenticated, don't show banner + if (session.authed) { + return null; + } + + // If dismissed and not triggered by edit, don't show + if (isDismissed && !triggeredByEdit) { + return null; + } + + const handleDismiss = () => { + sessionStorage.setItem('anonymousBannerDismissed', 'true'); + setIsDismissed(true); + }; + + const handleSignUpClick = () => { + setShowSignUp(true); + }; + + const handleSignUpSuccess = () => { + setShowSignUp(false); + if (onAuthenticated) { + onAuthenticated(); + } + }; + + const handleSignUpCancel = () => { + setShowSignUp(false); + }; + + // Show CryptID modal when sign up is clicked + if (showSignUp) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+ + + +
+ +
+ {triggeredByEdit ? ( +

+ Want to edit this board? +

+ ) : ( +

+ You're viewing this board anonymously +

+ )} + + {isExpanded ? ( +
+

+ Sign in by creating a username as your CryptID — no password required! +

+
    +
  • + 🔒 + Secured with encrypted keys, right in your browser, by a W3C standard algorithm +
  • +
  • + 💾 + Your session is stored for offline access, encrypted in browser storage by the same key +
  • +
  • + 📦 + Full data portability — use your canvas securely any time you like +
  • +
+
+ ) : ( +

+ Create a free CryptID to edit this board — no password needed! +

+ )} +
+ +
+ + + {!triggeredByEdit && ( + + )} + + {!isExpanded && ( + + )} +
+
+ + {triggeredByEdit && ( +
+ + + + This board is in read-only mode for anonymous viewers +
+ )} +
+ ); +}; + +export default AnonymousViewerBanner; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 52c1485..1f9151c 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,7 +1,9 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'; -import { Session, SessionError } from '../lib/auth/types'; +import { Session, SessionError, PermissionLevel } from '../lib/auth/types'; import { AuthService } from '../lib/auth/authService'; import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence'; +import { WORKER_URL } from '../constants/workerUrl'; +import * as crypto from '../lib/auth/crypto'; interface AuthContextType { session: Session; @@ -12,6 +14,12 @@ interface AuthContextType { login: (username: string) => Promise; register: (username: string) => Promise; logout: () => Promise; + /** Fetch and cache the user's permission level for a specific board */ + fetchBoardPermission: (boardId: string) => Promise; + /** Check if user can edit the current board */ + canEdit: () => boolean; + /** Check if user is admin for the current board */ + isAdmin: () => boolean; } const initialSession: Session = { @@ -167,6 +175,82 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }, [clearSession]); + /** + * Fetch and cache the user's permission level for a specific board + */ + const fetchBoardPermission = useCallback(async (boardId: string): Promise => { + // Check cache first + if (session.boardPermissions?.[boardId]) { + return session.boardPermissions[boardId]; + } + + try { + // Get public key for auth header if user is authenticated + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (session.authed && session.username) { + const publicKey = crypto.getPublicKey(session.username); + if (publicKey) { + headers['X-CryptID-PublicKey'] = publicKey; + } + } + + const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + console.error('Failed to fetch board permission:', response.status); + // Default to 'view' for unauthenticated, 'edit' for authenticated + return session.authed ? 'edit' : 'view'; + } + + const data = await response.json() as { + permission: PermissionLevel; + isOwner: boolean; + boardExists: boolean; + }; + + // Cache the permission + setSessionState(prev => ({ + ...prev, + currentBoardPermission: data.permission, + boardPermissions: { + ...prev.boardPermissions, + [boardId]: data.permission, + }, + })); + + return data.permission; + } catch (error) { + console.error('Error fetching board permission:', error); + // Default to 'view' for unauthenticated, 'edit' for authenticated + return session.authed ? 'edit' : 'view'; + } + }, [session.authed, session.username, session.boardPermissions]); + + /** + * Check if user can edit the current board + */ + const canEdit = useCallback((): boolean => { + const permission = session.currentBoardPermission; + if (!permission) { + // If no permission set, default based on auth status + return session.authed; + } + return permission === 'edit' || permission === 'admin'; + }, [session.currentBoardPermission, session.authed]); + + /** + * Check if user is admin for the current board + */ + const isAdmin = useCallback((): boolean => { + return session.currentBoardPermission === 'admin'; + }, [session.currentBoardPermission]); + // Initialize on mount useEffect(() => { try { @@ -190,8 +274,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => initialize, login, register, - logout - }), [session, setSession, clearSession, initialize, login, register, logout]); + logout, + fetchBoardPermission, + canEdit, + isAdmin, + }), [session, setSession, clearSession, initialize, login, register, logout, fetchBoardPermission, canEdit, isAdmin]); return ( diff --git a/src/css/anonymous-banner.css b/src/css/anonymous-banner.css new file mode 100644 index 0000000..cd95f8d --- /dev/null +++ b/src/css/anonymous-banner.css @@ -0,0 +1,323 @@ +/* Anonymous Viewer Banner Styles */ + +.anonymous-viewer-banner { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + + max-width: 600px; + width: calc(100% - 40px); + + background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 16px; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 0 40px rgba(139, 92, 246, 0.15); + + animation: slideUp 0.4s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.anonymous-viewer-banner.edit-triggered { + background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%); + border-color: rgba(236, 72, 153, 0.4); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 0 40px rgba(236, 72, 153, 0.2); +} + +.banner-content { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 20px; +} + +.banner-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 12px; + background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; +} + +.edit-triggered .banner-icon { + background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%); +} + +.banner-text { + flex: 1; + min-width: 0; +} + +.banner-headline { + margin: 0 0 8px 0; + font-size: 16px; + color: #f0f0f0; + line-height: 1.4; +} + +.banner-headline strong { + color: white; +} + +.banner-summary { + margin: 0; + font-size: 14px; + color: #a0a0b0; + line-height: 1.5; +} + +.banner-details { + margin-top: 8px; +} + +.banner-details p { + margin: 0 0 12px 0; + font-size: 14px; + color: #c0c0d0; + line-height: 1.5; +} + +.banner-details strong { + color: #8b5cf6; +} + +.cryptid-benefits { + margin: 0; + padding: 0; + list-style: none; +} + +.cryptid-benefits li { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; + font-size: 13px; + color: #a0a0b0; + line-height: 1.4; +} + +.cryptid-benefits li:last-child { + margin-bottom: 0; +} + +.benefit-icon { + flex-shrink: 0; + font-size: 14px; +} + +.cryptid-benefits a { + color: #8b5cf6; + text-decoration: none; +} + +.cryptid-benefits a:hover { + text-decoration: underline; +} + +.banner-actions { + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.banner-signup-btn { + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + color: white; + background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.banner-signup-btn:hover { + background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%); + box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4); + transform: translateY(-1px); +} + +.edit-triggered .banner-signup-btn { + background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%); +} + +.edit-triggered .banner-signup-btn:hover { + background: linear-gradient(135deg, #db2777 0%, #9333ea 100%); + box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4); +} + +.banner-dismiss-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + color: #808090; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.banner-dismiss-btn:hover { + color: #f0f0f0; + background: rgba(255, 255, 255, 0.1); +} + +.banner-expand-btn { + padding: 6px 12px; + font-size: 12px; + color: #8b5cf6; + background: transparent; + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.banner-expand-btn:hover { + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.5); +} + +.banner-edit-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: rgba(236, 72, 153, 0.1); + border-top: 1px solid rgba(236, 72, 153, 0.2); + border-radius: 0 0 16px 16px; + font-size: 13px; + color: #f472b6; +} + +/* Modal overlay for CryptID sign-up */ +.anonymous-banner-modal-overlay { + position: fixed; + inset: 0; + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.anonymous-banner-modal { + max-width: 420px; + width: calc(100% - 40px); + max-height: calc(100vh - 80px); + overflow-y: auto; + background: #1e1e2e; + border-radius: 16px; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5); + animation: scaleIn 0.3s ease-out; +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Dark mode is default, light mode adjustments */ +@media (prefers-color-scheme: light) { + .anonymous-viewer-banner { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-color: rgba(139, 92, 246, 0.2); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.1), + 0 0 40px rgba(139, 92, 246, 0.1); + } + + .banner-headline { + color: #2d2d44; + } + + .banner-headline strong { + color: #1e1e2e; + } + + .banner-summary, + .banner-details p, + .cryptid-benefits li { + color: #606080; + } + + .banner-dismiss-btn { + color: #606080; + } + + .banner-dismiss-btn:hover { + color: #2d2d44; + background: rgba(0, 0, 0, 0.05); + } +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .anonymous-viewer-banner { + bottom: 10px; + max-width: none; + width: calc(100% - 20px); + border-radius: 12px; + } + + .banner-content { + flex-direction: column; + padding: 16px; + } + + .banner-icon { + width: 40px; + height: 40px; + } + + .banner-actions { + flex-direction: row; + width: 100%; + margin-top: 12px; + } + + .banner-signup-btn { + flex: 1; + } +} diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts index 67d681b..0b7e051 100644 --- a/src/lib/auth/types.ts +++ b/src/lib/auth/types.ts @@ -1,3 +1,11 @@ +/** + * Permission levels for board access: + * - 'view': Read-only access, cannot create/edit/delete shapes + * - 'edit': Can create, edit, and delete shapes + * - 'admin': Full access including permission management and board settings + */ +export type PermissionLevel = 'view' | 'edit' | 'admin'; + export interface Session { username: string; authed: boolean; @@ -6,6 +14,10 @@ export interface Session { obsidianVaultPath?: string; obsidianVaultName?: string; error?: string; + // Board permission for current board (populated when viewing a board) + currentBoardPermission?: PermissionLevel; + // Cache of board permissions by board ID + boardPermissions?: Record; } export enum SessionError { diff --git a/worker/AutomergeDurableObject.ts b/worker/AutomergeDurableObject.ts index b185ed4..c265109 100644 --- a/worker/AutomergeDurableObject.ts +++ b/worker/AutomergeDurableObject.ts @@ -283,9 +283,11 @@ export class AutomergeDurableObject { serverWebSocket.addEventListener("close", (event) => { console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`) this.clients.delete(sessionId) - // Clean up sync manager state for this peer + // Clean up sync manager state for this peer and flush pending saves if (this.syncManager) { - this.syncManager.handlePeerDisconnect(sessionId) + this.syncManager.handlePeerDisconnect(sessionId).catch((error) => { + console.error(`❌ Error handling peer disconnect:`, error) + }) } }) diff --git a/worker/automerge-sync-manager.ts b/worker/automerge-sync-manager.ts index 7c5fe10..4d2a351 100644 --- a/worker/automerge-sync-manager.ts +++ b/worker/automerge-sync-manager.ts @@ -262,12 +262,18 @@ export class AutomergeSyncManager { /** * Handle peer disconnection - * Clean up sync state but don't lose any data + * Clean up sync state and flush any pending saves */ - handlePeerDisconnect(peerId: string): void { + async handlePeerDisconnect(peerId: string): Promise { if (this.peerSyncStates.has(peerId)) { this.peerSyncStates.delete(peerId) console.log(`👋 Peer disconnected: ${peerId}`) + + // If there's a pending save, flush it immediately to prevent data loss + if (this.pendingSave) { + console.log(`💾 Flushing pending save on peer disconnect`) + await this.forceSave() + } } } diff --git a/worker/boardPermissions.ts b/worker/boardPermissions.ts new file mode 100644 index 0000000..abeeea5 --- /dev/null +++ b/worker/boardPermissions.ts @@ -0,0 +1,581 @@ +import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User } from './types'; + +// Generate a UUID v4 +function generateUUID(): string { + return crypto.randomUUID(); +} + +/** + * Get a user's effective permission for a board + * Priority: explicit permission > board owner (admin) > default permission + */ +export async function getEffectivePermission( + db: D1Database, + boardId: string, + userId: string | null +): Promise { + // Check if board exists + const board = await db.prepare( + 'SELECT * FROM boards WHERE id = ?' + ).bind(boardId).first(); + + // Board doesn't exist - treat as new board, anyone authenticated can create + if (!board) { + // If user is authenticated, they can create the board (will become owner) + // If not authenticated, view-only (they'll see empty canvas but can't edit) + return { + permission: userId ? 'edit' : 'view', + isOwner: false, + boardExists: false + }; + } + + // If user is not authenticated, return default permission + if (!userId) { + return { + permission: board.default_permission as PermissionLevel, + isOwner: false, + boardExists: true + }; + } + + // Check if user is the board owner (always admin) + if (board.owner_id === userId) { + return { + permission: 'admin', + isOwner: true, + boardExists: true + }; + } + + // Check for explicit permission + const explicitPerm = await db.prepare( + 'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?' + ).bind(boardId, userId).first(); + + if (explicitPerm) { + return { + permission: explicitPerm.permission, + isOwner: false, + boardExists: true + }; + } + + // Fall back to default permission, but authenticated users get at least 'edit' + // (unless board explicitly restricts to view-only) + const defaultPerm = board.default_permission as PermissionLevel; + + // For most boards, authenticated users can edit + // Board owners can set default_permission to 'view' to restrict this + return { + permission: defaultPerm === 'view' ? 'view' : 'edit', + isOwner: false, + boardExists: 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 + */ +export async function handleGetPermission( + boardId: string, + request: Request, + env: Environment +): Promise { + try { + const db = env.CRYPTID_DB; + if (!db) { + // No database - default to edit for backwards compatibility + return new Response(JSON.stringify({ + permission: 'edit', + 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'); + + 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; + } + } + + const result = await getEffectivePermission(db, boardId, userId); + + 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? } + */ +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? } + */ +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; + }; + + 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 (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, + 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' }, + }); + } +} diff --git a/worker/schema.sql b/worker/schema.sql index d61cc80..ee4a185 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -45,3 +45,98 @@ CREATE INDEX IF NOT EXISTS idx_device_keys_pubkey ON device_keys(public_key); CREATE INDEX IF NOT EXISTS idx_tokens_token ON verification_tokens(token); CREATE INDEX IF NOT EXISTS idx_tokens_email ON verification_tokens(email); CREATE INDEX IF NOT EXISTS idx_tokens_expires ON verification_tokens(expires_at); + +-- ============================================================================= +-- Board Permissions System +-- ============================================================================= + +-- Board ownership and default permissions +-- Each board has an owner (admin) and a default permission level for new visitors +CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, -- board slug/room ID (e.g., "mycofi33") + owner_id TEXT, -- user ID of creator (NULL for legacy boards) + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + -- Default permission for unauthenticated users: 'view' (read-only) or 'edit' (open) + default_permission TEXT DEFAULT 'view' CHECK (default_permission IN ('view', 'edit')), + -- Board metadata + name TEXT, -- Optional display name + description TEXT, -- Optional description + is_public INTEGER DEFAULT 1, -- 1 = anyone with link can view, 0 = invite only + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL +); + +-- Per-user board permissions +-- Overrides the board's default permission for specific users +CREATE TABLE IF NOT EXISTS board_permissions ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + user_id TEXT NOT NULL, + -- Permission levels: 'view' (read-only), 'edit' (can modify), 'admin' (full access) + permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')), + granted_by TEXT, -- user ID who granted permission (NULL for owner) + granted_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(board_id, user_id) +); + +-- Board permission indexes +CREATE INDEX IF NOT EXISTS idx_boards_owner ON boards(owner_id); +CREATE INDEX IF NOT EXISTS idx_board_perms_board ON board_permissions(board_id); +CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id); +CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id); + +-- ============================================================================= +-- User Networking / Social Graph System +-- ============================================================================= + +-- User profiles with searchable usernames and display info +-- Extends the users table with public profile data +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id TEXT PRIMARY KEY, -- References users.id + display_name TEXT, -- Optional display name (defaults to username) + bio TEXT, -- Short bio + avatar_color TEXT, -- Hex color for avatar + is_searchable INTEGER DEFAULT 1, -- 1 = appears in search results + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- User connections (one-way following) +-- from_user follows to_user (asymmetric) +CREATE TABLE IF NOT EXISTS user_connections ( + id TEXT PRIMARY KEY, + from_user_id TEXT NOT NULL, -- User who initiated the connection + to_user_id TEXT NOT NULL, -- User being connected to + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(from_user_id, to_user_id) -- Can only connect once +); + +-- Edge metadata (private notes/labels on connections) +-- Each user can have their own metadata for a connection edge +CREATE TABLE IF NOT EXISTS connection_metadata ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, -- References user_connections.id + user_id TEXT NOT NULL, -- Which party owns this metadata + label TEXT, -- Short label (e.g., "Met at ETHDenver") + notes TEXT, -- Private notes about the connection + color TEXT, -- Custom edge color (hex) + strength INTEGER DEFAULT 5 CHECK (strength >= 1 AND strength <= 10), -- 1-10 connection strength + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (connection_id) REFERENCES user_connections(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(connection_id, user_id) -- One metadata entry per user per connection +); + +-- User networking indexes +CREATE INDEX IF NOT EXISTS idx_profiles_searchable ON user_profiles(is_searchable); +CREATE INDEX IF NOT EXISTS idx_connections_from ON user_connections(from_user_id); +CREATE INDEX IF NOT EXISTS idx_connections_to ON user_connections(to_user_id); +CREATE INDEX IF NOT EXISTS idx_connections_both ON user_connections(from_user_id, to_user_id); +CREATE INDEX IF NOT EXISTS idx_conn_meta_connection ON connection_metadata(connection_id); +CREATE INDEX IF NOT EXISTS idx_conn_meta_user ON connection_metadata(user_id); diff --git a/worker/types.ts b/worker/types.ts index 2abb101..8f78500 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -48,4 +48,51 @@ export interface VerificationToken { public_key?: string; device_name?: string; user_agent?: string; +} + +// ============================================================================= +// Board Permission Types +// ============================================================================= + +/** + * Permission levels for board access: + * - 'view': Read-only access, cannot create/edit/delete shapes + * - 'edit': Can create, edit, and delete shapes + * - 'admin': Full access including permission management and board settings + */ +export type PermissionLevel = 'view' | 'edit' | 'admin'; + +/** + * Board record in the database + */ +export interface Board { + id: string; // board slug/room ID + owner_id: string | null; // user ID of creator (NULL for legacy boards) + created_at: string; + updated_at: string; + default_permission: 'view' | 'edit'; + name: string | null; + description: string | null; + is_public: number; // SQLite boolean (0 or 1) +} + +/** + * Board permission record for a specific user + */ +export interface BoardPermission { + id: string; + board_id: string; + user_id: string; + permission: PermissionLevel; + granted_by: string | null; + granted_at: string; +} + +/** + * Response when checking a user's permission for a board + */ +export interface PermissionCheckResult { + permission: PermissionLevel; + isOwner: boolean; + boardExists: boolean; } \ No newline at end of file