From 85ac897a1a82788d900c7937fe9e9bade113c7ca Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 22:58:37 -0800 Subject: [PATCH] fix: use single-segment admin endpoints to bypass Cloudflare redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/index.ts | 128 +++++++++++++++++++++++++++++++++++++++++++-- server/spaces.ts | 112 +-------------------------------------- website/admin.html | 17 +++--- 3 files changed, 135 insertions(+), 122 deletions(-) diff --git a/server/index.ts b/server/index.ts index 94f7f8e..6d69006 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 = {}; + for (const shape of Object.values(shapes)) { + const t = (shape as Record).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"); diff --git a/server/spaces.ts b/server/spaces.ts index 506cd57..ae7e47e 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -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(); @@ -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 = {}; - for (const shape of Object.values(shapes)) { - const t = (shape as Record).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 }; diff --git a/website/admin.html b/website/admin.html index b079e45..c37c61f 100644 --- a/website/admin.html +++ b/website/admin.html @@ -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 = '

Access Denied

Your account does not have admin privileges.

'; @@ -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(() => ({}));