fix: use single-segment admin endpoints to bypass Cloudflare redirect

Cloudflare has a wildcard rule that redirects any multi-segment path
on rspace.online to a subdomain (e.g. /foo/bar → foo.rspace.online/bar).
This broke both /api/* and /admin/api/* paths.

Replace with single-segment endpoints:
- GET /admin-data — returns spaces + modules (admin-only)
- POST /admin-action — handles mutations like delete-space

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 22:58:37 -08:00
parent caae204c2b
commit 85ac897a1a
3 changed files with 135 additions and 122 deletions

View File

@ -25,6 +25,8 @@ import {
updateShape,
updateShapeFields,
cascadePermissions,
listCommunities,
deleteCommunity,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
@ -65,7 +67,7 @@ import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces, adminApi } from "./spaces";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
@ -134,9 +136,6 @@ app.get("/.well-known/webauthn", (c) => {
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── Admin API (bypasses Cloudflare /api/* redirect rule) ──
app.route("/admin/api", adminApi);
// ── mi — AI assistant endpoint ──
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
@ -865,7 +864,18 @@ app.get("/create-space", async (c) => {
// Legacy redirect
app.get("/new", (c) => c.redirect("/create-space", 301));
// Admin dashboard
// ── Admin dashboard & API ──
// NOTE: Cloudflare has a wildcard rule that redirects any multi-segment path
// (rspace.online/foo/bar → foo.rspace.online/bar), so all admin endpoints
// must be single-segment paths. Use POST /admin-action for mutations.
const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);
function isAdminDID(did: string | undefined): boolean {
return !!did && ADMIN_DIDS.includes(did);
}
// Serve admin HTML page
app.get("/admin", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "admin.html"));
if (await file.exists()) {
@ -874,6 +884,114 @@ app.get("/admin", async (c) => {
return c.text("Admin", 200);
});
// Admin data endpoint (GET) — returns spaces list + modules
app.get("/admin-data", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data?.meta) continue;
const shapes = data.shapes || {};
const members = data.members || {};
const shapeCount = Object.keys(shapes).length;
const memberCount = Object.keys(members).length;
let fileSizeBytes = 0;
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
fileSizeBytes = s.size;
} catch {
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
fileSizeBytes = s.size;
} catch { /* not on disk yet */ }
}
const shapeTypes: Record<string, number> = {};
for (const shape of Object.values(shapes)) {
const t = (shape as Record<string, unknown>).type as string || "unknown";
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
}
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: data.meta.visibility || "public_read",
createdAt: data.meta.createdAt,
ownerDID: data.meta.ownerDID,
shapeCount,
memberCount,
fileSizeBytes,
shapeTypes,
});
}
spacesList.sort((a, b) => {
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return db - da;
});
return c.json({
spaces: spacesList,
total: spacesList.length,
modules: getModuleInfoList(),
});
});
// Admin action endpoint (POST) — mutations (delete space, etc.)
app.post("/admin-action", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const body = await c.req.json();
const { action, slug } = body;
if (action === "delete-space" && slug) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(slug); } catch (e) {
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
}
// Clean up EncryptID space_members
try {
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (e) {
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
}
await deleteCommunity(slug);
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
}
return c.json({ error: "Unknown action" }, 400);
});
// Space root: /:space → space dashboard
app.get("/:space", (c) => {
const space = c.req.param("space");

View File

@ -41,7 +41,7 @@ import {
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules, getModuleInfoList } from "../shared/module";
import { getAllModules } from "../shared/module";
// ── In-memory pending nest requests (move to DB later) ──
const nestRequests = new Map<string, PendingNestRequest>();
@ -1125,112 +1125,4 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
});
// ── Admin API router (mounted separately to bypass Cloudflare /api/* redirect) ──
const adminApi = new Hono();
adminApi.get("/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
adminApi.get("/spaces", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data?.meta) continue;
const shapes = data.shapes || {};
const members = data.members || {};
const shapeCount = Object.keys(shapes).length;
const memberCount = Object.keys(members).length;
let fileSizeBytes = 0;
try {
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
fileSizeBytes = s.size;
} catch {
try {
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
fileSizeBytes = s.size;
} catch { /* not on disk yet */ }
}
const shapeTypes: Record<string, number> = {};
for (const shape of Object.values(shapes)) {
const t = (shape as Record<string, unknown>).type as string || "unknown";
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
}
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: data.meta.visibility || "public_read",
createdAt: data.meta.createdAt,
ownerDID: data.meta.ownerDID,
shapeCount,
memberCount,
fileSizeBytes,
shapeTypes,
});
}
spacesList.sort((a, b) => {
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return db - da;
});
return c.json({ spaces: spacesList, total: spacesList.length });
});
adminApi.delete("/spaces/:slug", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403);
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(slug); } catch (e) {
console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
}
for (const [id, req] of accessRequests) {
if (req.spaceSlug === slug) accessRequests.delete(id);
}
for (const [id, req] of nestRequests) {
if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
}
try {
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (e) {
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
}
await deleteCommunity(slug);
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
});
export { spaces, adminApi };
export { spaces };

View File

@ -502,9 +502,8 @@
RStackSpaceSwitcher.define();
RStackMi.define();
fetch("/admin/api/modules").then(r => r.json()).then(data => {
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
}).catch(() => {});
// NOTE: modules are loaded from /admin-data along with spaces (single-segment
// path to bypass Cloudflare wildcard subdomain redirect on multi-segment paths)
const ENCRYPTID_URL = "https://auth.rspace.online";
@ -528,7 +527,7 @@
}
try {
const res = await fetch("/admin/api/spaces", { headers: authHeaders() });
const res = await fetch("/admin-data", { headers: authHeaders() });
if (res.status === 401 || res.status === 403) {
document.getElementById("auth-gate").innerHTML =
'<div class="auth-gate"><h2>Access Denied</h2><p>Your account does not have admin privileges.</p></div>';
@ -541,6 +540,9 @@
document.getElementById("auth-gate").style.display = "none";
document.getElementById("admin-content").style.display = "block";
// Load modules into app switcher
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
// Load spaces data
allSpaces = data.spaces || [];
updateSpaceStats();
@ -738,9 +740,10 @@
if (!confirm(`Delete space "${name}" (${slug})?\n\nThis will permanently delete all shapes, members, and data. This cannot be undone.`)) return;
try {
const res = await fetch(`/admin/api/spaces/${encodeURIComponent(slug)}`, {
method: "DELETE",
headers: authHeaders(),
const res = await fetch("/admin-action", {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete-space", slug }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));