feat: enforce EncryptID auth on communities with configurable visibility

Protect POST /api/communities, GET /api/communities/:slug, and WebSocket
upgrade with token verification. Add visibility (public/public_read/
authenticated/members_only) and ownerDID to community metadata. Block
writes from read-only connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 11:54:10 -07:00
parent e5af01119b
commit 9b8784a0ac
3 changed files with 142 additions and 16 deletions

View File

@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@automerge/automerge": "^2.2.8", "@automerge/automerge": "^2.2.8",
"@encryptid/sdk": "file:../encryptid-sdk",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
"hono": "^4.11.7", "hono": "^4.11.7",
"postgres": "^3.4.5", "postgres": "^3.4.5",

View File

@ -3,10 +3,14 @@ import * as Automerge from "@automerge/automerge";
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
export type SpaceVisibility = 'public' | 'public_read' | 'authenticated' | 'members_only';
export interface CommunityMeta { export interface CommunityMeta {
name: string; name: string;
slug: string; slug: string;
createdAt: string; createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
} }
export interface ShapeData { export interface ShapeData {
@ -138,13 +142,20 @@ export async function saveCommunity(slug: string): Promise<void> {
/** /**
* Create a new community * Create a new community
*/ */
export async function createCommunity(name: string, slug: string): Promise<Automerge.Doc<CommunityDoc>> { export async function createCommunity(
name: string,
slug: string,
ownerDID: string | null = null,
visibility: SpaceVisibility = 'public_read',
): Promise<Automerge.Doc<CommunityDoc>> {
let doc = Automerge.init<CommunityDoc>(); let doc = Automerge.init<CommunityDoc>();
doc = Automerge.change(doc, "Create community", (d) => { doc = Automerge.change(doc, "Create community", (d) => {
d.meta = { d.meta = {
name, name,
slug, slug,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
visibility,
ownerDID,
}; };
d.shapes = {}; d.shapes = {};
}); });

View File

@ -11,6 +11,30 @@ import {
removePeerSyncState, removePeerSyncState,
updateShape, updateShape,
} from "./community-store"; } 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<SpaceAuthConfig | null> {
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 PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../dist"); const DIST_DIR = resolve(import.meta.dir, "../dist");
@ -19,6 +43,8 @@ const DIST_DIR = resolve(import.meta.dir, "../dist");
interface WSData { interface WSData {
communitySlug: string; communitySlug: string;
peerId: string; peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
} }
// Track connected clients per community (for broadcasting) // Track connected clients per community (for broadcasting)
@ -97,13 +123,29 @@ const server = Bun.serve<WSData>({
const host = req.headers.get("host"); const host = req.headers.get("host");
const subdomain = getSubdomain(host); const subdomain = getSubdomain(host);
// Handle WebSocket upgrade // Handle WebSocket upgrade (with auth for non-public communities)
if (url.pathname.startsWith("/ws/")) { if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2]; const communitySlug = url.pathname.split("/")[2];
if (communitySlug) { 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 peerId = generatePeerId();
const upgraded = server.upgrade(req, { const upgraded = server.upgrade(req, {
data: { communitySlug, peerId }, data: { communitySlug, peerId, claims, readOnly },
}); });
if (upgraded) return undefined; if (upgraded) return undefined;
} }
@ -189,6 +231,14 @@ const server = Bun.serve<WSData>({
const msg = JSON.parse(message.toString()); const msg = JSON.parse(message.toString());
if (msg.type === "sync" && Array.isArray(msg.data)) { 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 // Handle Automerge sync message
const syncMessage = new Uint8Array(msg.data); const syncMessage = new Uint8Array(msg.data);
const result = receiveSyncMessage(communitySlug, peerId, syncMessage); const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
@ -236,6 +286,10 @@ const server = Bun.serve<WSData>({
} }
// Legacy message handling for backward compatibility // Legacy message handling for backward compatibility
else if (msg.type === "update" && msg.id && msg.data) { 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); updateShape(communitySlug, msg.id, msg.data);
// Broadcast to other clients // Broadcast to other clients
const clients = communityClients.get(communitySlug); const clients = communityClients.get(communitySlug);
@ -248,6 +302,10 @@ const server = Bun.serve<WSData>({
} }
} }
} else if (msg.type === "delete" && msg.id) { } 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); deleteShape(communitySlug, msg.id);
// Broadcast to other clients // Broadcast to other clients
const clients = communityClients.get(communitySlug); const clients = communityClients.get(communitySlug);
@ -289,19 +347,42 @@ const server = Bun.serve<WSData>({
async function handleAPI(req: Request, url: URL): Promise<Response> { async function handleAPI(req: Request, url: URL): Promise<Response> {
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}; };
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); 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") { if (url.pathname === "/api/communities" && req.method === "POST") {
try { try {
const body = (await req.json()) as { name?: string; slug?: string }; // Require EncryptID authentication to create a community
const { name, slug } = body; 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) { if (!name || !slug) {
return Response.json( return Response.json(
@ -318,6 +399,15 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
); );
} }
// 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 // Check if exists
if (await communityExists(slug)) { if (await communityExists(slug)) {
return Response.json( return Response.json(
@ -326,12 +416,18 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
); );
} }
// Create community // Create community with owner and visibility
await createCommunity(name, slug); await createCommunity(name, slug, claims.sub, visibility);
// Return URL to new community // Return URL to new community
return Response.json( return Response.json(
{ url: `https://${slug}.rspace.online`, slug, name }, {
url: `https://${slug}.rspace.online`,
slug,
name,
visibility,
ownerDID: claims.sub,
},
{ headers: corsHeaders } { headers: corsHeaders }
); );
} catch (e) { } catch (e) {
@ -343,13 +439,25 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
} }
} }
// 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") { if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
const slug = url.pathname.split("/")[3]; 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) { if (!data) {
// Try loading from disk
await loadCommunity(slug); await loadCommunity(slug);
const loadedData = getDocumentData(slug); const loadedData = getDocumentData(slug);
if (!loadedData) { if (!loadedData) {
@ -358,10 +466,16 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
{ status: 404, headers: corsHeaders } { 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 }); return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });