Merge branch 'dev'
This commit is contained in:
commit
6e94a457cb
133
server/index.ts
133
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()));
|
||||
});
|
||||
|
||||
|
|
|
|||
115
server/shell.ts
115
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 =
|
||||
'<div class="access-gate__card">' +
|
||||
'<div class="access-gate__icon">🔒</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">🔒</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">🔒</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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue