Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-22 13:49:59 -07:00
commit a38cd7bb5d
2 changed files with 136 additions and 7 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 } 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<WSData>({
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';

View File

@ -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 = `
<div class="access-denied">
<div class="access-denied__card">
<div class="access-denied__icon">&#x1f512;</div>
<h1 class="access-denied__title">Private Space</h1>
<p class="access-denied__desc">You don't have access to <strong>${escapeHtml(spaceSlug)}</strong>.</p>
<p class="access-denied__hint">This space is private. Ask the owner to invite you as a member.</p>
<a href="${escapeAttr(homeHref)}" class="access-denied__btn">${escapeHtml(homeLabel)}</a>
</div>
</div>`;
return renderShell({
title: `Access Denied — ${spaceSlug} | rSpace`,
moduleId: "rspace",
spaceSlug,
modules,
body,
styles: `<style>${ACCESS_DENIED_CSS}</style>`,
});
}
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 {