diff --git a/package.json b/package.json index 2624d98..f95f83a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@automerge/automerge": "^2.2.8", + "@encryptid/sdk": "file:../encryptid-sdk", "@lit/reactive-element": "^2.0.4", "hono": "^4.11.7", "postgres": "^3.4.5", diff --git a/server/community-store.ts b/server/community-store.ts index 5c2c867..3a82263 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -3,10 +3,14 @@ import * as Automerge from "@automerge/automerge"; const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; +export type SpaceVisibility = 'public' | 'public_read' | 'authenticated' | 'members_only'; + export interface CommunityMeta { name: string; slug: string; createdAt: string; + visibility: SpaceVisibility; + ownerDID: string | null; } export interface ShapeData { @@ -138,13 +142,20 @@ export async function saveCommunity(slug: string): Promise { /** * Create a new community */ -export async function createCommunity(name: string, slug: string): Promise> { +export async function createCommunity( + name: string, + slug: string, + ownerDID: string | null = null, + visibility: SpaceVisibility = 'public_read', +): Promise> { let doc = Automerge.init(); doc = Automerge.change(doc, "Create community", (d) => { d.meta = { name, slug, createdAt: new Date().toISOString(), + visibility, + ownerDID, }; d.shapes = {}; }); diff --git a/server/index.ts b/server/index.ts index 153079a..4896f22 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,30 @@ import { removePeerSyncState, updateShape, } from "./community-store"; +import type { SpaceVisibility } from "./community-store"; +import { + verifyEncryptIDToken, + evaluateSpaceAccess, + extractToken, + authenticateWSUpgrade, +} from "@encryptid/sdk/server"; +import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; + +/** Resolve a community slug to its SpaceAuthConfig for the SDK guard */ +async function getSpaceConfig(slug: string): Promise { + let doc = getDocumentData(slug); + if (!doc) { + await loadCommunity(slug); + doc = getDocumentData(slug); + } + if (!doc) return null; + return { + spaceSlug: slug, + visibility: (doc.meta.visibility || "public_read") as SpaceVisibility, + ownerDID: doc.meta.ownerDID || undefined, + app: "rspace", + }; +} const PORT = Number(process.env.PORT) || 3000; const DIST_DIR = resolve(import.meta.dir, "../dist"); @@ -19,6 +43,8 @@ const DIST_DIR = resolve(import.meta.dir, "../dist"); interface WSData { communitySlug: string; peerId: string; + claims: EncryptIDClaims | null; + readOnly: boolean; } // Track connected clients per community (for broadcasting) @@ -97,13 +123,29 @@ const server = Bun.serve({ const host = req.headers.get("host"); const subdomain = getSubdomain(host); - // Handle WebSocket upgrade + // Handle WebSocket upgrade (with auth for non-public communities) if (url.pathname.startsWith("/ws/")) { const communitySlug = url.pathname.split("/")[2]; if (communitySlug) { + // Check space visibility — authenticate if needed + const spaceConfig = await getSpaceConfig(communitySlug); + const claims = await authenticateWSUpgrade(req); + let readOnly = false; + + if (spaceConfig) { + const vis = spaceConfig.visibility; + if (vis === "authenticated" || vis === "members_only") { + if (!claims) { + return new Response("Authentication required to join this space", { status: 401 }); + } + } else if (vis === "public_read") { + readOnly = !claims; + } + } + const peerId = generatePeerId(); const upgraded = server.upgrade(req, { - data: { communitySlug, peerId }, + data: { communitySlug, peerId, claims, readOnly }, }); if (upgraded) return undefined; } @@ -189,6 +231,14 @@ const server = Bun.serve({ const msg = JSON.parse(message.toString()); if (msg.type === "sync" && Array.isArray(msg.data)) { + // Block sync writes from read-only connections + if (ws.data.readOnly) { + ws.send(JSON.stringify({ + type: "error", + message: "Authentication required to edit this space", + })); + return; + } // Handle Automerge sync message const syncMessage = new Uint8Array(msg.data); const result = receiveSyncMessage(communitySlug, peerId, syncMessage); @@ -236,6 +286,10 @@ const server = Bun.serve({ } // Legacy message handling for backward compatibility else if (msg.type === "update" && msg.id && msg.data) { + if (ws.data.readOnly) { + ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" })); + return; + } updateShape(communitySlug, msg.id, msg.data); // Broadcast to other clients const clients = communityClients.get(communitySlug); @@ -248,6 +302,10 @@ const server = Bun.serve({ } } } else if (msg.type === "delete" && msg.id) { + if (ws.data.readOnly) { + ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" })); + return; + } deleteShape(communitySlug, msg.id); // Broadcast to other clients const clients = communityClients.get(communitySlug); @@ -289,19 +347,42 @@ const server = Bun.serve({ async function handleAPI(req: Request, url: URL): Promise { const corsHeaders = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", }; if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } - // POST /api/communities - Create new community + // POST /api/communities - Create new community (requires auth) if (url.pathname === "/api/communities" && req.method === "POST") { try { - const body = (await req.json()) as { name?: string; slug?: string }; - const { name, slug } = body; + // Require EncryptID authentication to create a community + const token = extractToken(req.headers); + if (!token) { + return Response.json( + { error: "Authentication required to create a community" }, + { status: 401, headers: corsHeaders } + ); + } + + let claims: EncryptIDClaims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return Response.json( + { error: "Invalid or expired authentication token" }, + { status: 401, headers: corsHeaders } + ); + } + + const body = (await req.json()) as { + name?: string; + slug?: string; + visibility?: SpaceVisibility; + }; + const { name, slug, visibility = "public_read" } = body; if (!name || !slug) { return Response.json( @@ -318,6 +399,15 @@ async function handleAPI(req: Request, url: URL): Promise { ); } + // Validate visibility + const validVisibilities = ["public", "public_read", "authenticated", "members_only"]; + if (!validVisibilities.includes(visibility)) { + return Response.json( + { error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, + { status: 400, headers: corsHeaders } + ); + } + // Check if exists if (await communityExists(slug)) { return Response.json( @@ -326,12 +416,18 @@ async function handleAPI(req: Request, url: URL): Promise { ); } - // Create community - await createCommunity(name, slug); + // Create community with owner and visibility + await createCommunity(name, slug, claims.sub, visibility); // Return URL to new community return Response.json( - { url: `https://${slug}.rspace.online`, slug, name }, + { + url: `https://${slug}.rspace.online`, + slug, + name, + visibility, + ownerDID: claims.sub, + }, { headers: corsHeaders } ); } catch (e) { @@ -343,13 +439,25 @@ async function handleAPI(req: Request, url: URL): Promise { } } - // GET /api/communities/:slug - Get community info + // GET /api/communities/:slug - Get community info (respects visibility) if (url.pathname.startsWith("/api/communities/") && req.method === "GET") { const slug = url.pathname.split("/")[3]; - const data = getDocumentData(slug); + // Check space access using SDK guard + const token = extractToken(req.headers); + const access = await evaluateSpaceAccess(slug, token, req.method, { + getSpaceConfig, + }); + + if (!access.allowed) { + return Response.json( + { error: access.reason }, + { status: access.claims ? 403 : 401, headers: corsHeaders } + ); + } + + const data = getDocumentData(slug); if (!data) { - // Try loading from disk await loadCommunity(slug); const loadedData = getDocumentData(slug); if (!loadedData) { @@ -358,10 +466,16 @@ async function handleAPI(req: Request, url: URL): Promise { { status: 404, headers: corsHeaders } ); } - return Response.json({ meta: loadedData.meta }, { headers: corsHeaders }); + return Response.json( + { meta: loadedData.meta, readOnly: access.readOnly }, + { headers: corsHeaders } + ); } - return Response.json({ meta: data.meta }, { headers: corsHeaders }); + return Response.json( + { meta: data.meta, readOnly: access.readOnly }, + { headers: corsHeaders } + ); } return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });