fix(auth): use client-side access gate for private spaces

Server-side HTML gates can't work because auth tokens are in
localStorage, not cookies. Replaced with:
- Client-side gate checks session, then verifies membership via
  new /api/space-access/:slug endpoint
- Shows "Sign In" for unauthenticated users
- Shows "no access + return to your space" for non-members
- Server-side gates remain for API/JSON requests and WebSocket

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-22 14:03:28 -07:00
parent fe7157ffe1
commit eb470dff18
2 changed files with 155 additions and 93 deletions

View File

@ -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()));
});

View File

@ -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 =
'<div class="access-gate__card">' +
'<div class="access-gate__icon">&#x1F512;</div>' +
'<h2 class="access-gate__title">Private Space</h2>' +
'<p class="access-gate__desc">This space is private. Sign in to continue.</p>' +
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
'</div>';
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 =
'<div class="access-gate__card">' +
'<div class="access-gate__icon">&#x1F512;</div>' +
'<h2 class="access-gate__title">Private Space</h2>' +
'<p class="access-gate__desc">Sign in to access this space.</p>' +
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
'</div>';
} 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 =
'<div class="access-gate__card">' +
'<div class="access-gate__icon">&#x1F512;</div>' +
'<h2 class="access-gate__title">Private Space</h2>' +
'<p class="access-gate__desc">You don\'t have access to <strong>' + slug + '</strong>.</p>' +
'<p class="access-gate__hint">This space is private. Ask the owner to invite you as a member.</p>' +
'<a href="' + homeHref + '" class="access-gate__btn">' + homeLabel + '</a>' +
'</div>';
}
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;
}