diff --git a/server/index.ts b/server/index.ts index d91e26a..f20dbd7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -78,7 +78,7 @@ import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; -import { renderShell, renderSubPageInfo, renderOnboarding } from "./shell"; +import { renderShell, renderSubPageInfo, renderOnboarding, renderAccessDenied } from "./shell"; import { renderOutputListPage } from "./output-list"; import { renderMainLanding, renderSpaceDashboard } from "./landing"; import { syncServer } from "./sync-instance"; @@ -2070,9 +2070,43 @@ for (const mod of getAllModules()) { const effectiveSpace = resolveDataSpace(mod.id, space, overrides); c.set("effectiveSpace", effectiveSpace); - // Resolve caller's role for write-method blocking + // ── Access control for private spaces ── + const vis = doc?.meta?.visibility || "private"; + const accept = c.req.header("Accept") || ""; + const isHtmlRequest = accept.includes("text/html"); + let authResolved = false; + + if (vis === "private") { + const token = extractToken(c.req.raw.headers); + let claims: EncryptIDClaims | null = null; + if (token) { + try { claims = await verifyEncryptIDToken(token); } catch {} + } + const resolved = await resolveCallerRole(space, claims); + if (resolved) { + c.set("spaceRole", resolved.role); + c.set("isOwner", resolved.isOwner); + authResolved = true; + } + // Non-members (viewer role, not owner) cannot access private spaces + if (!claims || (resolved && !resolved.isOwner && resolved.role === "viewer")) { + if (isHtmlRequest) { + const userSlug = claims?.username || undefined; + return c.html(renderAccessDenied({ spaceSlug: space, modules: getModuleInfoList(), userSlug }), 403); + } + return c.json({ error: "You don't have access to this space" }, 403); + } + } else if (vis === "permissioned" && !isHtmlRequest) { + // API requests to permissioned spaces require auth + const token = extractToken(c.req.raw.headers); + if (!token) { + return c.json({ error: "Authentication required" }, 401); + } + } + + // Resolve caller's role for write-method blocking (skip if already resolved) const method = c.req.method; - if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { + if (!authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { const token = extractToken(c.req.raw.headers); let claims: EncryptIDClaims | null = null; if (token) { @@ -2086,6 +2120,12 @@ for (const mod of getAllModules()) { return c.json({ error: "Write access required" }, 403); } } + } else if (authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { + // Already resolved for private space — just check the write gate + const role = c.get("spaceRole") as SpaceRoleString | undefined; + if (role === "viewer") { + return c.json({ error: "Write access required" }, 403); + } } return next(); @@ -2287,10 +2327,28 @@ app.post("/admin-action", async (c) => { }); // Space root: /:space → space dashboard -app.get("/:space", (c) => { +app.get("/:space", async (c) => { const space = c.req.param("space"); // Don't serve dashboard for static file paths if (space.includes(".")) return c.notFound(); + + // Gate private spaces: non-members see access denied page + await loadCommunity(space); + const doc = getDocumentData(space); + const vis = doc?.meta?.visibility || "private"; + if (vis === "private") { + const token = extractToken(c.req.raw.headers); + let claims: EncryptIDClaims | null = null; + if (token) { + try { claims = await verifyEncryptIDToken(token); } catch {} + } + const resolved = await resolveCallerRole(space, claims); + if (!claims || (resolved && !resolved.isOwner && resolved.role === "viewer")) { + const userSlug = claims?.username || undefined; + return c.html(renderAccessDenied({ spaceSlug: space, modules: getModuleInfoList(), userSlug }), 403); + } + } + return c.html(renderSpaceDashboard(space, getModuleInfoList())); }); @@ -2549,18 +2607,30 @@ const server = Bun.serve({ let readOnly = false; let spaceRole: WSData['spaceRole'] = null; + // Load doc early so we can check membership for private spaces + await loadCommunity(communitySlug); + const spaceData = getDocumentData(communitySlug); + if (spaceConfig) { const vis = spaceConfig.visibility; if (vis === "permissioned" || vis === "private") { if (!claims) return new Response("Authentication required", { status: 401 }); - } else if (vis === "public") { + } + // Reject non-members of private spaces + if (vis === "private" && claims && spaceData) { + const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid; + const isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid]; + if (!isOwner && !isMember) { + return new Response("You don't have access to this space", { status: 403 }); + } + } + if (vis === "public") { readOnly = !claims; } } // Resolve the caller's space role - await loadCommunity(communitySlug); - const spaceData = getDocumentData(communitySlug); if (spaceData) { if (claims && spaceData.meta.ownerDID === claims.sub) { spaceRole = 'admin'; diff --git a/server/shell.ts b/server/shell.ts index ab66d29..b581dc9 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -2544,6 +2544,65 @@ const ONBOARDING_CSS = ` } `; +// ── Access denied page ── + +export function renderAccessDenied(opts: { spaceSlug: string; modules: ModuleInfo[]; userSlug?: string }): string { + const { spaceSlug, modules, userSlug } = opts; + const homeHref = userSlug + ? `https://${userSlug}.rspace.online/` + : `https://rspace.online/`; + const homeLabel = userSlug ? `Return to ${userSlug}` : "Return to rSpace"; + + const body = ` +
+
+
🔒
+

Private Space

+

You don't have access to ${escapeHtml(spaceSlug)}.

+

This space is private. Ask the owner to invite you as a member.

+ ${escapeHtml(homeLabel)} +
+
`; + + return renderShell({ + title: `Access Denied — ${spaceSlug} | rSpace`, + moduleId: "rspace", + spaceSlug, + modules, + body, + styles: ``, + }); +} + +const ACCESS_DENIED_CSS = ` +.access-denied { + display: flex; flex-direction: column; align-items: center; justify-content: center; + min-height: calc(70vh - 56px); padding: 3rem 1.5rem 2rem; +} +.access-denied__card { + text-align: center; max-width: 420px; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; padding: 2.5rem 2rem; +} +.access-denied__icon { font-size: 3rem; margin-bottom: 1rem; } +.access-denied__title { + font-size: 1.5rem; font-weight: 600; margin: 0 0 0.75rem; + color: var(--color-text, #e0e0e0); +} +.access-denied__desc { + color: var(--color-text-muted, #aaa); margin: 0 0 0.5rem; font-size: 1rem; +} +.access-denied__hint { + color: var(--color-text-muted, #888); margin: 0 0 1.5rem; font-size: 0.875rem; +} +.access-denied__btn { + display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px; + background: var(--color-primary, #6366f1); color: #fff; text-decoration: none; + font-weight: 500; font-size: 0.9rem; transition: opacity 0.15s; +} +.access-denied__btn:hover { opacity: 0.85; } +`; + // ── Demo page CSS utilities (rd-* prefix, parallel to rl-* landing pages) ── export function escapeHtml(s: string): string {