Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-22 14:03:37 -07:00
commit 6e94a457cb
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 { crowdsurfModule } from "../modules/crowdsurf/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } 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 { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing"; import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { syncServer } from "./sync-instance"; import { syncServer } from "./sync-instance";
@ -645,6 +645,31 @@ app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => {
return c.json({ ok: true }); 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 // GET /api/communities/:slug — community info
app.get("/api/communities/:slug", async (c) => { app.get("/api/communities/:slug", async (c) => {
const slug = c.req.param("slug"); const slug = c.req.param("slug");
@ -2070,61 +2095,59 @@ for (const mod of getAllModules()) {
const effectiveSpace = resolveDataSpace(mod.id, space, overrides); const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
c.set("effectiveSpace", effectiveSpace); 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 vis = doc?.meta?.visibility || "private";
const accept = c.req.header("Accept") || ""; const accept = c.req.header("Accept") || "";
const isHtmlRequest = accept.includes("text/html"); const isHtmlRequest = accept.includes("text/html");
let authResolved = false;
if (vis === "private") { if (!isHtmlRequest && (vis === "private" || vis === "permissioned")) {
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); const token = extractToken(c.req.raw.headers);
if (!token) { if (!token) {
return c.json({ error: "Authentication required" }, 401); 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; 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 (vis === "private") {
if (resolved) { const resolved = await resolveCallerRole(space, claims);
c.set("spaceRole", resolved.role); if (resolved) {
c.set("isOwner", resolved.isOwner); c.set("spaceRole", resolved.role);
if (resolved.role === "viewer") { c.set("isOwner", resolved.isOwner);
return c.json({ error: "Write access required" }, 403); }
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; // Resolve caller's role for write-method blocking
if (role === "viewer") { const method = c.req.method;
return c.json({ error: "Write access required" }, 403); 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 // 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"); const space = c.req.param("space");
// Don't serve dashboard for static file paths // Don't serve dashboard for static file paths
if (space.includes(".")) return c.notFound(); 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())); return c.html(renderSpaceDashboard(space, getModuleInfoList()));
}); });

View File

@ -613,42 +613,96 @@ export function renderShell(opts: ShellOptions): string {
}; };
// ── Private space access gate ── // ── 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() { (function() {
var vis = document.body.getAttribute('data-space-visibility'); 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 { try {
var raw = localStorage.getItem('encryptid_session'); var raw = localStorage.getItem('encryptid_session');
if (raw) { if (raw) session = JSON.parse(raw);
var session = JSON.parse(raw);
if (session && session.accessToken) return;
}
} catch(e) {} } 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'); var main = document.getElementById('app');
if (main) main.style.display = 'none'; fetch('/api/space-access/' + encodeURIComponent(slug), {
var gate = document.createElement('div'); headers: { 'Authorization': 'Bearer ' + session.accessToken }
gate.id = 'rspace-access-gate'; })
gate.innerHTML = .then(function(r) { return r.json(); })
'<div class="access-gate__card">' + .then(function(data) {
'<div class="access-gate__icon">&#x1F512;</div>' + if (data.access) return; // member/owner — allow through
'<h2 class="access-gate__title">Private Space</h2>' + showGate('no-access');
'<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>' + .catch(function() {
'</div>'; // API error — don't block, let WS gate handle it
document.body.appendChild(gate); });
var btn = document.getElementById('gate-signin');
if (btn) btn.addEventListener('click', function() { function showGate(mode) {
var identity = document.querySelector('rstack-identity'); var main = document.getElementById('app');
if (identity && identity.showAuthModal) { if (main) main.style.display = 'none';
identity.showAuthModal({ var gate = document.createElement('div');
onSuccess: function() { gate.id = 'rspace-access-gate';
gate.remove();
if (main) main.style.display = ''; 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 ── // ── Tab bar / Layer system initialization ──
@ -1437,10 +1491,11 @@ const ACCESS_GATE_CSS = `
background: linear-gradient(135deg, #06b6d4, #7c3aed); background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; -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 { .access-gate__btn {
padding: 12px 32px; border-radius: 8px; border: none; display: inline-block; padding: 12px 32px; border-radius: 8px; border: none;
font-size: 1rem; font-weight: 600; cursor: pointer; font-size: 1rem; font-weight: 600; cursor: pointer; text-decoration: none;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
transition: opacity 0.15s, transform 0.15s; transition: opacity 0.15s, transform 0.15s;
} }