feat: implement user permissions system (view/edit/admin)

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-05 22:27:12 -08:00
parent 037e232b85
commit c00106e2b7
10 changed files with 1408 additions and 7 deletions

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
**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)
<!-- SECTION:NOTES:END -->

View File

@ -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<AnonymousViewerBannerProps> = ({
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 (
<div className="anonymous-banner-modal-overlay">
<div className="anonymous-banner-modal">
<CryptID
onSuccess={handleSignUpSuccess}
onCancel={handleSignUpCancel}
/>
</div>
</div>
);
}
return (
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
<div className="banner-content">
<div className="banner-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
</svg>
</div>
<div className="banner-text">
{triggeredByEdit ? (
<p className="banner-headline">
<strong>Want to edit this board?</strong>
</p>
) : (
<p className="banner-headline">
<strong>You're viewing this board anonymously</strong>
</p>
)}
{isExpanded ? (
<div className="banner-details">
<p>
Sign in by creating a username as your <strong>CryptID</strong> &mdash; no password required!
</p>
<ul className="cryptid-benefits">
<li>
<span className="benefit-icon">&#x1F512;</span>
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
</li>
<li>
<span className="benefit-icon">&#x1F4BE;</span>
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
</li>
<li>
<span className="benefit-icon">&#x1F4E6;</span>
<span>Full data portability &mdash; use your canvas securely any time you like</span>
</li>
</ul>
</div>
) : (
<p className="banner-summary">
Create a free CryptID to edit this board &mdash; no password needed!
</p>
)}
</div>
<div className="banner-actions">
<button
className="banner-signup-btn"
onClick={handleSignUpClick}
>
Create CryptID
</button>
{!triggeredByEdit && (
<button
className="banner-dismiss-btn"
onClick={handleDismiss}
title="Dismiss"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
</svg>
</button>
)}
{!isExpanded && (
<button
className="banner-expand-btn"
onClick={() => setIsExpanded(true)}
title="Learn more"
>
Learn more
</button>
)}
</div>
</div>
{triggeredByEdit && (
<div className="banner-edit-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
</svg>
<span>This board is in read-only mode for anonymous viewers</span>
</div>
)}
</div>
);
};
export default AnonymousViewerBanner;

View File

@ -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<boolean>;
register: (username: string) => Promise<boolean>;
logout: () => Promise<void>;
/** Fetch and cache the user's permission level for a specific board */
fetchBoardPermission: (boardId: string) => Promise<PermissionLevel>;
/** 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<PermissionLevel> => {
// 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<string, string> = {
'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 (
<AuthContext.Provider value={contextValue}>

View File

@ -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;
}
}

View File

@ -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<string, PermissionLevel>;
}
export enum SessionError {

View File

@ -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)
})
}
})

View File

@ -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<void> {
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()
}
}
}

581
worker/boardPermissions.ts Normal file
View File

@ -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<PermissionCheckResult> {
// Check if board exists
const board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
// 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<BoardPermission>();
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<Board> {
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<Board>();
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<Board> {
let board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
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<Response> {
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<Response> {
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<BoardPermission & { cryptid_username: string; email: string }>();
// Get board info
const board = await db.prepare(
'SELECT * FROM boards WHERE id = ?'
).bind(boardId).first<Board>();
// 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<Response> {
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<Response> {
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<Response> {
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<Board>();
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' },
});
}
}

View File

@ -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);

View File

@ -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;
}