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:
parent
e5af01119b
commit
9b8784a0ac
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
144
server/index.ts
144
server/index.ts
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue