Merge branch 'dev'
This commit is contained in:
commit
6e94a457cb
103
server/index.ts
103
server/index.ts
|
|
@ -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,43 +2095,41 @@ 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);
|
||||||
}
|
}
|
||||||
|
let claims: EncryptIDClaims | null = null;
|
||||||
|
try { claims = await verifyEncryptIDToken(token); } catch {}
|
||||||
|
if (!claims) {
|
||||||
|
return c.json({ error: "Authentication required" }, 401);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve caller's role for write-method blocking (skip if already resolved)
|
// Resolve caller's role for write-method blocking
|
||||||
const method = c.req.method;
|
const method = c.req.method;
|
||||||
if (!authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
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);
|
const token = extractToken(c.req.raw.headers);
|
||||||
let claims: EncryptIDClaims | null = null;
|
let claims: EncryptIDClaims | null = null;
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -2120,13 +2143,13 @@ for (const mod of getAllModules()) {
|
||||||
return c.json({ error: "Write access required" }, 403);
|
return c.json({ error: "Write access required" }, 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
} else {
|
||||||
// Already resolved for private space — just check the write gate
|
|
||||||
const role = c.get("spaceRole") as SpaceRoleString | undefined;
|
const role = c.get("spaceRole") as SpaceRoleString | undefined;
|
||||||
if (role === "viewer") {
|
if (role === "viewer") {
|
||||||
return c.json({ error: "Write access required" }, 403);
|
return c.json({ error: "Write access required" }, 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
@ -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()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
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');
|
var main = document.getElementById('app');
|
||||||
if (main) main.style.display = 'none';
|
if (main) main.style.display = 'none';
|
||||||
var gate = document.createElement('div');
|
var gate = document.createElement('div');
|
||||||
gate.id = 'rspace-access-gate';
|
gate.id = 'rspace-access-gate';
|
||||||
|
|
||||||
|
if (mode === 'sign-in') {
|
||||||
gate.innerHTML =
|
gate.innerHTML =
|
||||||
'<div class="access-gate__card">' +
|
'<div class="access-gate__card">' +
|
||||||
'<div class="access-gate__icon">🔒</div>' +
|
'<div class="access-gate__icon">🔒</div>' +
|
||||||
'<h2 class="access-gate__title">Private Space</h2>' +
|
'<h2 class="access-gate__title">Private Space</h2>' +
|
||||||
'<p class="access-gate__desc">This space is private. Sign in to continue.</p>' +
|
'<p class="access-gate__desc">Sign in to access this space.</p>' +
|
||||||
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
|
'<button class="access-gate__btn" id="gate-signin">Sign In</button>' +
|
||||||
'</div>';
|
'</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);
|
document.body.appendChild(gate);
|
||||||
|
|
||||||
|
if (mode === 'sign-in') {
|
||||||
var btn = document.getElementById('gate-signin');
|
var btn = document.getElementById('gate-signin');
|
||||||
if (btn) btn.addEventListener('click', function() {
|
if (btn) btn.addEventListener('click', function() {
|
||||||
var identity = document.querySelector('rstack-identity');
|
var identity = document.querySelector('rstack-identity');
|
||||||
if (identity && identity.showAuthModal) {
|
if (identity && identity.showAuthModal) {
|
||||||
identity.showAuthModal({
|
identity.showAuthModal({
|
||||||
onSuccess: function() {
|
onSuccess: function() {
|
||||||
gate.remove();
|
// After sign-in, reload to re-check membership
|
||||||
if (main) main.style.display = '';
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue