fix: bypass Cloudflare /api/* redirect for admin dashboard

Cloudflare has a redirect rule that rewrites rspace.online/api/* to
http://api.rspace.online/*, causing Mixed Content errors in the browser.
Add a separate /admin/api router that serves the same admin data at
paths that don't trigger the redirect rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 22:51:23 -08:00
parent 8d77c6eee8
commit caae204c2b
3 changed files with 117 additions and 6 deletions

View File

@ -65,7 +65,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 } from "./spaces";
import { spaces, adminApi } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
@ -134,6 +134,9 @@ 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";

View File

@ -41,7 +41,7 @@ import {
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules } from "../shared/module";
import { getAllModules, getModuleInfoList } from "../shared/module";
// ── In-memory pending nest requests (move to DB later) ──
const nestRequests = new Map<string, PendingNestRequest>();
@ -1125,4 +1125,112 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
});
export { spaces };
// ── 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 };

View File

@ -502,7 +502,7 @@
RStackSpaceSwitcher.define();
RStackMi.define();
fetch("/api/modules").then(r => r.json()).then(data => {
fetch("/admin/api/modules").then(r => r.json()).then(data => {
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
}).catch(() => {});
@ -528,7 +528,7 @@
}
try {
const res = await fetch("/api/spaces/admin", { headers: authHeaders() });
const res = await fetch("/admin/api/spaces", { 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>';
@ -738,7 +738,7 @@
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(`/api/spaces/admin/${encodeURIComponent(slug)}`, {
const res = await fetch(`/admin/api/spaces/${encodeURIComponent(slug)}`, {
method: "DELETE",
headers: authHeaders(),
});