diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 48efe6c..d95b76c 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -33,7 +33,7 @@ export default function RoomPage() { const params = useParams(); const router = useRouter(); const slug = params.slug as string; - const { did: encryptIdDid } = useAuthStore(); + const { did: encryptIdDid, token: authToken } = useAuthStore(); const [showShare, setShowShare] = useState(false); const [showParticipants, setShowParticipants] = useState(true); @@ -95,6 +95,7 @@ export default function RoomPage() { userName: currentUser?.name || '', userEmoji: currentUser?.emoji || '👤', encryptIdDid, + authToken, }); // Use refs to avoid stale closures in callbacks diff --git a/src/app/page.tsx b/src/app/page.tsx index 08ed137..c604721 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -63,6 +63,12 @@ export default function HomePage() { const handleCreateRoom = async () => { if (!name.trim()) return; + // Require EncryptID auth to create rooms + if (!isAuthenticated) { + alert('Please sign in with EncryptID to create a room.'); + return; + } + const slug = roomName.trim() ? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20) : generateSlug(); @@ -169,9 +175,10 @@ export default function HomePage() {
or
diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts index fd4beb9..fab7cec 100644 --- a/src/hooks/useRoom.ts +++ b/src/hooks/useRoom.ts @@ -99,6 +99,8 @@ interface UseRoomOptions { userEmoji: string; /** EncryptID DID for persistent cross-session identity (optional) */ encryptIdDid?: string | null; + /** EncryptID JWT token for authenticated sync server access (optional) */ + authToken?: string | null; } interface UseRoomReturn { @@ -119,7 +121,7 @@ interface UseRoomReturn { setLocationRequestCallback: (callback: () => void) => void; } -export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOptions): UseRoomReturn { +export function useRoom({ slug, userName, userEmoji, encryptIdDid, authToken }: UseRoomOptions): UseRoomReturn { const [isConnected, setIsConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -182,12 +184,14 @@ export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOpti participantIdRef.current = participantId; const color = COLORS[Math.floor(Math.random() * COLORS.length)]; - // Create sync instance + // Create sync instance (pass auth token for server-side access control) const sync = new RoomSync( slug, participantId, handleStateChange, - handleConnectionChange + handleConnectionChange, + undefined, + authToken ); syncRef.current = sync; diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 9993418..dc7a224 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -106,19 +106,22 @@ export class RoomSync { private onLocationRequest: LocationRequestCallback | null = null; private participantId: string; private currentParticipant: ParticipantState | null = null; + private authToken: string | null = null; constructor( slug: string, participantId: string, onStateChange: SyncCallback, onConnectionChange: ConnectionCallback, - onLocationRequest?: LocationRequestCallback + onLocationRequest?: LocationRequestCallback, + authToken?: string | null ) { this.slug = slug; this.participantId = participantId; this.onStateChange = onStateChange; this.onConnectionChange = onConnectionChange; this.onLocationRequest = onLocationRequest || null; + this.authToken = authToken || null; // Initialize or load state this.state = this.loadState() || this.createInitialState(); @@ -215,7 +218,10 @@ export class RoomSync { } try { - this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`); + const wsUrl = this.authToken + ? `${syncUrl}/room/${this.slug}?token=${encodeURIComponent(this.authToken)}` + : `${syncUrl}/room/${this.slug}`; + this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('Connected to sync server'); diff --git a/sync-server/server.js b/sync-server/server.js index 8a445cd..a6e12b8 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -3,6 +3,7 @@ import { createServer } from 'http'; import { parse } from 'url'; import { randomUUID } from 'crypto'; import webpush from 'web-push'; +import { verifyToken, extractTokenFromURL } from './verify-token.js'; const PORT = process.env.PORT || 3001; const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour @@ -25,7 +26,7 @@ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { // Room state storage: Map const rooms = new Map(); -// Client tracking: Map +// Client tracking: Map const clients = new Map(); // Push subscriptions: Map> @@ -40,7 +41,9 @@ function getRoomState(slug) { createdAt: new Date().toISOString(), participants: {}, waypoints: [], - lastActivity: Date.now() + lastActivity: Date.now(), + visibility: 'public', // public | public_read | authenticated | members_only + ownerDID: null, }); } const room = rooms.get(slug); @@ -48,6 +51,41 @@ function getRoomState(slug) { return room; } +/** + * Evaluate whether a connection should be allowed based on room visibility. + * Returns { allowed, readOnly, reason } + */ +function evaluateRoomAccess(room, claims, isRead = false) { + const { visibility, ownerDID } = room; + const isOwner = !!(claims && ownerDID && (claims.sub === ownerDID || claims.did === ownerDID)); + + if (visibility === 'public') { + return { allowed: true, readOnly: false, isOwner }; + } + + if (visibility === 'public_read') { + // Everyone can connect, but writes require auth + return { allowed: true, readOnly: !claims, isOwner }; + } + + if (visibility === 'authenticated') { + if (!claims) { + return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' }; + } + return { allowed: true, readOnly: false, isOwner }; + } + + if (visibility === 'members_only') { + if (!claims) { + return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' }; + } + // Further membership checks could be added here + return { allowed: true, readOnly: false, isOwner }; + } + + return { allowed: false, readOnly: false, isOwner: false, reason: 'Unknown visibility' }; +} + function cleanupStaleParticipants(room) { const now = Date.now(); const staleIds = []; @@ -130,6 +168,13 @@ function handleMessage(ws, data) { const room = getRoomState(clientInfo.roomSlug); + // Block write operations from readOnly connections + const writeOps = ['join', 'leave', 'location', 'status', 'waypoint_add', 'waypoint_remove']; + if (clientInfo.readOnly && writeOps.includes(message.type)) { + ws.send(JSON.stringify({ type: 'error', error: 'Read-only access — authenticate to interact' })); + return; + } + switch (message.type) { case 'join': { const participant = { @@ -358,7 +403,7 @@ async function parseJsonBody(req) { function addCorsHeaders(res) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); } // Create HTTP server for health checks and push subscription endpoints @@ -469,6 +514,53 @@ const server = createServer(async (req, res) => { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to send test' })); } + } else if (pathname?.startsWith('/room/') && pathname.endsWith('/config') && req.method === 'POST') { + // Set room visibility and config (requires auth + ownership) + try { + const roomSlug = pathname.replace('/room/', '').replace('/config', ''); + const body = await parseJsonBody(req); + const authHeader = req.headers['authorization'] || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + const claims = token ? await verifyToken(token) : null; + + if (!claims) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Authentication required' })); + return; + } + + const room = getRoomState(roomSlug); + + // Only the owner (or first authenticated user) can change room config + if (room.ownerDID && room.ownerDID !== claims.sub && room.ownerDID !== claims.did) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Only the room owner can change settings' })); + return; + } + + // Set owner if not set + if (!room.ownerDID) { + room.ownerDID = claims.sub || claims.did; + } + + const validVisibilities = ['public', 'public_read', 'authenticated', 'members_only']; + if (body.visibility && validVisibilities.includes(body.visibility)) { + room.visibility = body.visibility; + } + if (body.name) { + room.name = body.name; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + room: { slug: room.slug, visibility: room.visibility, ownerDID: room.ownerDID, name: room.name } + })); + } catch (error) { + console.error('Room config error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to update room config' })); + } } else if (pathname === '/push/request-location' && req.method === 'POST') { // Manually trigger location request for a room or specific participant // Uses WebSocket for online clients, push for offline/background @@ -577,7 +669,7 @@ const server = createServer(async (req, res) => { // Create WebSocket server const wss = new WebSocketServer({ server }); -wss.on('connection', (ws, req) => { +wss.on('connection', async (ws, req) => { const { pathname } = parse(req.url); // Extract room slug from path: /room/{slug} @@ -589,10 +681,37 @@ wss.on('connection', (ws, req) => { } const roomSlug = decodeURIComponent(match[1]); - console.log(`New connection to room: ${roomSlug}`); - // Register client - clients.set(ws, { roomSlug, participantId: null }); + // Extract and verify auth token from query string + const token = extractTokenFromURL(req.url); + let claims = null; + if (token) { + claims = await verifyToken(token); + if (claims) { + console.log(`[${roomSlug}] Authenticated connection: ${claims.username || claims.sub}`); + } + } + + // Check room access based on visibility + const room = getRoomState(roomSlug); + const access = evaluateRoomAccess(room, claims); + + if (!access.allowed) { + console.log(`[${roomSlug}] Connection rejected: ${access.reason}`); + ws.close(4001, access.reason || 'Access denied'); + return; + } + + console.log(`New connection to room: ${roomSlug}${access.readOnly ? ' (read-only)' : ''}`); + + // Register client with auth info + clients.set(ws, { roomSlug, participantId: null, claims, readOnly: access.readOnly }); + + // If authenticated and room has no owner, set this user as owner + if (claims && !room.ownerDID && Object.keys(room.participants).length === 0) { + room.ownerDID = claims.sub || claims.did; + console.log(`[${roomSlug}] Room owner set to: ${room.ownerDID}`); + } // Set up handlers ws.on('message', (data) => handleMessage(ws, data.toString())); diff --git a/sync-server/verify-token.js b/sync-server/verify-token.js new file mode 100644 index 0000000..2242f2c --- /dev/null +++ b/sync-server/verify-token.js @@ -0,0 +1,52 @@ +/** + * Lightweight EncryptID JWT verification for the sync server. + * + * Verifies tokens by calling the EncryptID server's /api/session/verify endpoint. + * No dependencies required — uses Node.js built-in fetch. + */ + +const ENCRYPTID_SERVER_URL = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; + +/** + * Verify an EncryptID JWT token by calling the EncryptID server. + * @param {string} token - The JWT token to verify + * @returns {Promise} The claims if valid, null otherwise + */ +export async function verifyToken(token) { + if (!token) return null; + + try { + const res = await fetch(`${ENCRYPTID_SERVER_URL.replace(/\/$/, '')}/api/session/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) return null; + + const data = await res.json(); + if (data.valid) { + return data.claims || {}; + } + return null; + } catch (error) { + console.error('Token verification failed:', error.message); + return null; + } +} + +/** + * Extract token from a URL query string. + * @param {string} url - The full URL or path with query string + * @returns {string|null} The token or null + */ +export function extractTokenFromURL(url) { + try { + // parse handles both full URLs and just paths with query strings + const searchParams = new URL(url, 'http://localhost').searchParams; + return searchParams.get('token') || null; + } catch { + return null; + } +}