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