diff --git a/server/index.ts b/server/index.ts index f20dbd7..9ffb57d 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, renderAccessDenied } from "./shell"; +import { renderShell, renderSubPageInfo, renderOnboarding } from "./shell"; import { renderOutputListPage } from "./output-list"; import { renderMainLanding, renderSpaceDashboard } from "./landing"; import { syncServer } from "./sync-instance"; @@ -645,6 +645,31 @@ app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => { return c.json({ ok: true }); }); +// GET /api/space-access/:slug — lightweight membership check for client-side gate +app.get("/api/space-access/:slug", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ access: false, reason: "not-authenticated" }); + let claims: EncryptIDClaims | null = null; + try { claims = await verifyEncryptIDToken(token); } catch {} + if (!claims) return c.json({ access: false, reason: "not-authenticated" }); + + const config = await getSpaceConfig(slug); + const vis = config?.visibility || "private"; + const resolved = await resolveCallerRole(slug, claims); + + if (vis === "private" && resolved && !resolved.isOwner && resolved.role === "viewer") { + return c.json({ access: false, reason: "not-member", visibility: vis, role: resolved.role }); + } + + return c.json({ + access: true, + visibility: vis, + role: resolved?.role || "viewer", + isOwner: resolved?.isOwner || false, + }); +}); + // GET /api/communities/:slug — community info app.get("/api/communities/:slug", async (c) => { const slug = c.req.param("slug"); @@ -2070,61 +2095,59 @@ for (const mod of getAllModules()) { const effectiveSpace = resolveDataSpace(mod.id, space, overrides); c.set("effectiveSpace", effectiveSpace); - // ── Access control for private spaces ── + // ── Access control for private/permissioned spaces (API requests only) ── + // HTML page navigations are gated client-side (tokens live in localStorage, + // not cookies, so the server can't check auth on browser navigations). + // The WebSocket gate prevents data sync for non-members regardless. 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 + if (!isHtmlRequest && (vis === "private" || vis === "permissioned")) { 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 (!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) { - try { claims = await verifyEncryptIDToken(token); } catch {} + try { claims = await verifyEncryptIDToken(token); } catch {} + if (!claims) { + return c.json({ error: "Authentication required" }, 401); } - const resolved = await resolveCallerRole(space, claims); - if (resolved) { - c.set("spaceRole", resolved.role); - c.set("isOwner", resolved.isOwner); - if (resolved.role === "viewer") { - return c.json({ error: "Write access required" }, 403); + if (vis === "private") { + const resolved = await resolveCallerRole(space, claims); + if (resolved) { + c.set("spaceRole", resolved.role); + c.set("isOwner", resolved.isOwner); + } + if (!resolved || (!resolved.isOwner && resolved.role === "viewer")) { + return c.json({ error: "You don't have access to this space" }, 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); + } + + // Resolve caller's role for write-method blocking + const method = c.req.method; + if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { + // Skip if already resolved above for private spaces + if (!c.get("spaceRole")) { + 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); + if (resolved.role === "viewer") { + return c.json({ error: "Write access required" }, 403); + } + } + } else { + const role = c.get("spaceRole") as SpaceRoleString | undefined; + if (role === "viewer") { + return c.json({ error: "Write access required" }, 403); + } } } @@ -2327,28 +2350,12 @@ app.post("/admin-action", async (c) => { }); // Space root: /:space → space dashboard -app.get("/:space", async (c) => { +// Access control for private spaces is handled client-side (tokens in localStorage) +// and enforced at the WebSocket layer (prevents data sync for non-members). +app.get("/:space", (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())); }); diff --git a/server/shell.ts b/server/shell.ts index b581dc9..8d8bcd4 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -613,42 +613,96 @@ export function renderShell(opts: ShellOptions): string { }; // ── Private space access gate ── - // If the space is private and no session exists, show a sign-in gate + // For private spaces: check session, then verify membership via API. + // For permissioned spaces: require sign-in but allow any authenticated user. (function() { var vis = document.body.getAttribute('data-space-visibility'); - if (vis !== 'private') return; + if (vis !== 'private' && vis !== 'permissioned') return; + var slug = document.body.getAttribute('data-space-slug') || ''; + + var session = null; try { var raw = localStorage.getItem('encryptid_session'); - if (raw) { - var session = JSON.parse(raw); - if (session && session.accessToken) return; - } + if (raw) session = JSON.parse(raw); } catch(e) {} - // No valid session — gate the content + + var hasToken = session && session.accessToken; + + // Permissioned spaces: only need a valid session + if (vis === 'permissioned') { + if (hasToken) return; // authenticated — allow through + showGate('sign-in'); + return; + } + + // Private spaces: need session + membership check + if (!hasToken) { + showGate('sign-in'); + return; + } + + // Have a token — verify membership via API var main = document.getElementById('app'); - if (main) main.style.display = 'none'; - var gate = document.createElement('div'); - gate.id = 'rspace-access-gate'; - gate.innerHTML = - '
' + - '
🔒
' + - '

Private Space

' + - '

This space is private. Sign in to continue.

' + - '' + - '
'; - document.body.appendChild(gate); - var btn = document.getElementById('gate-signin'); - if (btn) btn.addEventListener('click', function() { - var identity = document.querySelector('rstack-identity'); - if (identity && identity.showAuthModal) { - identity.showAuthModal({ - onSuccess: function() { - gate.remove(); - if (main) main.style.display = ''; + fetch('/api/space-access/' + encodeURIComponent(slug), { + headers: { 'Authorization': 'Bearer ' + session.accessToken } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.access) return; // member/owner — allow through + showGate('no-access'); + }) + .catch(function() { + // API error — don't block, let WS gate handle it + }); + + function showGate(mode) { + var main = document.getElementById('app'); + if (main) main.style.display = 'none'; + var gate = document.createElement('div'); + gate.id = 'rspace-access-gate'; + + if (mode === 'sign-in') { + gate.innerHTML = + '
' + + '
🔒
' + + '

Private Space

' + + '

Sign in to access this space.

' + + '' + + '
'; + } else { + var userSlug = ''; + try { if (session && session.claims && session.claims.username) userSlug = session.claims.username; } catch(e) {} + var homeHref = userSlug + ? 'https://' + userSlug + '.rspace.online/' + : 'https://rspace.online/'; + var homeLabel = userSlug ? 'Return to ' + userSlug : 'Return to rSpace'; + gate.innerHTML = + '
' + + '
🔒
' + + '

Private Space

' + + '

You don\'t have access to ' + slug + '.

' + + '

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

' + + '' + homeLabel + '' + + '
'; + } + + document.body.appendChild(gate); + + if (mode === 'sign-in') { + var btn = document.getElementById('gate-signin'); + if (btn) btn.addEventListener('click', function() { + var identity = document.querySelector('rstack-identity'); + if (identity && identity.showAuthModal) { + identity.showAuthModal({ + onSuccess: function() { + // After sign-in, reload to re-check membership + location.reload(); + } + }); } }); } - }); + } })(); // ── Tab bar / Layer system initialization ── @@ -1437,10 +1491,11 @@ const ACCESS_GATE_CSS = ` background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } -.access-gate__desc { color: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 1.5rem; } +.access-gate__desc { color: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 0.5rem; } +.access-gate__hint { color: var(--rs-text-secondary); font-size: 0.85rem; opacity: 0.7; margin: 0 0 1.5rem; } .access-gate__btn { - padding: 12px 32px; border-radius: 8px; border: none; - font-size: 1rem; font-weight: 600; cursor: pointer; + display: inline-block; padding: 12px 32px; border-radius: 8px; border: none; + font-size: 1rem; font-weight: 600; cursor: pointer; text-decoration: none; background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; transition: opacity 0.15s, transform 0.15s; }