fix: enforce subdomain routing — spaces are subdomains, never path segments

Audit and fix ~60 violations across 47 files where URLs were constructed
as /{space}/moduleId instead of /moduleId (subdomain provides the space).

- Add getModuleApiBase() helper to shared/url-helpers.ts
- Fix client components: use getModuleApiBase() for fetch URLs,
  rspaceNavUrl() for navigation
- Fix server routes: use c.get("isSubdomain") for redirects and
  JSON response URLs
- Fix OAuth callbacks (notion, google, clickup): subdomain-aware redirects
- Fix email notification URLs (rvote): use {space}.rspace.online format
- Fix webhook registration (rtasks/clickup): subdomain-aware endpoint URL
- Replace broken #getSpaceSlug() methods in folk-feed, folk-splat
- Replace NODE_ENV checks with proper isSubdomain checks (rdata, rnetwork)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 14:09:53 -07:00
parent 7c6861bf50
commit 4a54e6af16
47 changed files with 164 additions and 133 deletions

View File

@ -4,6 +4,7 @@
*/
import * as Automerge from "@automerge/automerge";
import { getModuleApiBase } from "../shared/url-helpers";
import type { CommunitySync } from "./community-sync";
import type {
CommentPinAnchor,
@ -535,7 +536,7 @@ export class CommentPinManager {
if (pin.linkedNoteId) {
html += `
<div style="padding: 8px 12px; background: #1a1a2e; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
<a href="/${this.#spaceSlug}/rnotes#${pin.linkedNoteId}" target="_blank"
<a href="${getModuleApiBase("rnotes")}#${pin.linkedNoteId}" target="_blank"
style="color: #a78bfa; text-decoration: none; font-size: 12px; font-weight: 600;">
📝 ${this.#escapeHtml(pin.linkedNoteTitle || "Linked note")}
</a>
@ -669,7 +670,7 @@ export class CommentPinManager {
if (this.#members) return this.#members;
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
const res = await fetch(`/${this.#spaceSlug}/api/space-members`, {
const res = await fetch(`${getModuleApiBase("rspace")}/api/space-members`, {
headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {},
});
if (!res.ok) return [];
@ -685,7 +686,7 @@ export class CommentPinManager {
if (this.#notes) return this.#notes;
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
const res = await fetch(`/${this.#spaceSlug}/rnotes/api/notes?limit=50`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/notes?limit=50`, {
headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {},
});
if (!res.ok) return [];
@ -816,7 +817,7 @@ export class CommentPinManager {
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
await fetch(`/${this.#spaceSlug}/api/comment-pins/notify-mention`, {
await fetch(`${getModuleApiBase("rspace")}/api/comment-pins/notify-mention`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -879,7 +880,7 @@ export class CommentPinManager {
};
if (email) body.notifyEmail = email;
const res = await fetch(`/${this.#spaceSlug}/rschedule/api/reminders`, {
const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),

View File

@ -7,6 +7,7 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
@ -299,7 +300,7 @@ export class FolkCommitmentPool extends FolkShape {
async #fetchCommitments() {
try {
const resp = await fetch(`/${this.#spaceSlug}/rtime/api/commitments`);
const resp = await fetch(`${getModuleApiBase("rtime")}/api/commitments`);
if (!resp.ok) return;
const data = await resp.json();
const commitments: PoolCommitment[] = data.commitments || [];

View File

@ -20,6 +20,7 @@
import { FolkShape } from "./folk-shape";
import { FLOW_COLORS, FLOW_LABELS } from "./layer-types";
import type { FlowKind } from "./layer-types";
import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../shared/url-helpers";
export class FolkFeed extends FolkShape {
static tagName = "folk-feed";
@ -121,8 +122,7 @@ export class FolkFeed extends FolkShape {
this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.sourceModule) {
const space = this.#getSpaceSlug();
window.location.href = `/${space}/${this.sourceModule}`;
window.location.href = rspaceNavUrl(getCurrentSpace(), this.sourceModule);
}
});
@ -150,9 +150,8 @@ export class FolkFeed extends FolkShape {
try {
// Construct feed URL based on feed ID
const space = this.#getSpaceSlug();
const feedEndpoint = this.#getFeedEndpoint();
const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`;
const url = `${getModuleApiBase(this.sourceModule)}/api/${feedEndpoint}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -257,12 +256,6 @@ export class FolkFeed extends FolkShape {
return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId;
}
#getSpaceSlug(): string {
// Try to get from URL
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[0] || "demo";
}
// ── Render feed items ──
#renderItems() {
@ -327,7 +320,6 @@ export class FolkFeed extends FolkShape {
/** Navigate to the source item in its module */
#navigateToItem(item: any) {
const space = this.#getSpaceSlug();
const mod = this.sourceModule;
// Build a deep link to the item in its source module
@ -338,7 +330,7 @@ export class FolkFeed extends FolkShape {
sourceModule: mod,
itemId: item.id,
item,
url: `/${space}/${mod}`,
url: getModuleApiBase(mod),
}
}));
}
@ -427,9 +419,8 @@ export class FolkFeed extends FolkShape {
// Write back to source module API
try {
const space = this.#getSpaceSlug();
const endpoint = this.#getWriteBackEndpoint(item);
const url = `/${space}/${this.sourceModule}/api/${endpoint}`;
const url = `${getModuleApiBase(this.sourceModule)}/api/${endpoint}`;
const method = this.#getWriteBackMethod();
const token = this.#getAuthToken();

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
import type {
RoomState,
ParticipantState,
@ -942,9 +943,7 @@ export class FolkMap extends FolkShape {
}
#getApiBase(): string {
const match = window.location.pathname.match(/^\/([^/]+)/);
const spaceSlug = match ? match[1] : "default";
return `/${spaceSlug}/rmaps`;
return getModuleApiBase("rmaps");
}
async #initRoomSync() {

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
const styles = css`
:host {
@ -295,8 +296,7 @@ export class FolkMultisigEmail extends FolkShape {
}
private getApiBase(): string {
const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo";
return `/${space}/rinbox`;
return getModuleApiBase("rinbox");
}
private getAuthHeaders(): Record<string, string> {

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
const PLATFORM_ICONS: Record<string, string> = {
x: "𝕏", linkedin: "in", instagram: "📷", youtube: "▶",
@ -331,7 +332,7 @@ export class FolkSocialCampaign extends FolkShape {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("navigate-to-module", {
bubbles: true, composed: true,
detail: { path: `/${this.#spaceSlug}/rsocials/campaigns?id=${this.#campaignId}` },
detail: { path: `${getModuleApiBase("rsocials")}/campaigns?id=${this.#campaignId}` },
}));
});

View File

@ -1,6 +1,7 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import type { SocialPlatform } from "./folk-social-post";
import { getModuleApiBase } from "../shared/url-helpers";
const PLATFORM_COLORS: Record<string, string> = {
x: "#000000",
@ -331,7 +332,7 @@ export class FolkSocialThread extends FolkShape {
this.dispatchEvent(new CustomEvent("navigate-to-module", {
bubbles: true,
composed: true,
detail: { path: `/${this.#spaceSlug}/rsocials/thread-editor?id=${this.#threadId}` },
detail: { path: `${getModuleApiBase("rsocials")}/thread-editor?id=${this.#threadId}` },
}));
});

View File

@ -7,6 +7,7 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
import {
computeSpider3D,
computeRadarPolygon,
@ -488,7 +489,7 @@ export class FolkSpider3D extends FolkShape {
try {
const token = localStorage.getItem("rspace-token") || "";
const res = await fetch(`/${this.#space}/connections`, {
const res = await fetch(`${getModuleApiBase("rspace")}/connections`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
const styles = css`
:host {
@ -327,8 +328,7 @@ export class FolkSplat extends FolkShape {
async #loadGallery(container: HTMLElement, viewerArea: HTMLElement) {
container.innerHTML = '<div class="loading"><div class="spinner"></div><span>Loading gallery...</span></div>';
try {
const spaceSlug = this.#getSpaceSlug();
const res = await fetch(`/${spaceSlug}/rsplat/api/splats?limit=20`);
const res = await fetch(`${getModuleApiBase("rsplat")}/api/splats?limit=20`);
if (!res.ok) throw new Error("Failed to load splats");
const data = await res.json();
this.#gallerySplats = data.splats || [];
@ -351,8 +351,7 @@ export class FolkSplat extends FolkShape {
const slug = (item as HTMLElement).dataset.slug;
const splat = this.#gallerySplats.find((s) => s.slug === slug);
if (splat) {
const spaceSlug = this.#getSpaceSlug();
const url = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`;
const url = `${getModuleApiBase("rsplat")}/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`;
if (this.#urlInput) this.#urlInput.value = url;
container.style.display = "none";
this.#loadSplat(url, viewerArea);
@ -410,19 +409,13 @@ export class FolkSplat extends FolkShape {
} catch (err) {
// Fallback: show as iframe pointing to splat viewer page
console.warn("[folk-splat] Three.js not available, using iframe fallback");
const spaceSlug = this.#getSpaceSlug();
const slug = url.split("/").filter(Boolean).pop()?.split(".")[0] || "";
viewerArea.innerHTML = `<iframe src="/${spaceSlug}/rsplat/view/${slug}" style="width:100%;height:100%;border:none;border-radius:0 0 8px 8px"></iframe>`;
viewerArea.innerHTML = `<iframe src="${getModuleApiBase("rsplat")}/view/${slug}" style="width:100%;height:100%;border:none;border-radius:0 0 8px 8px"></iframe>`;
} finally {
this.#isLoading = false;
}
}
#getSpaceSlug(): string {
const pathParts = window.location.pathname.split("/").filter(Boolean);
return pathParts[0] || "demo";
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;

View File

@ -7,6 +7,7 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
// Skill constants (mirrored from rtime/schemas)
const SKILL_COLORS: Record<string, string> = {
@ -404,7 +405,7 @@ export class FolkTaskRequest extends FolkShape {
// Get auth token from cookie or localStorage
const token = document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("auth_token="))?.split("=")[1]
|| localStorage.getItem("auth_token") || "";
await fetch(`/${this.#spaceSlug}/rtime/api/connections`, {
await fetch(`${getModuleApiBase("rtime")}/api/connections`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -6,6 +6,7 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers";
const styles = css`
:host {
@ -283,8 +284,7 @@ export class FolkTransactionBuilder extends FolkShape {
}
#apiBase(): string {
const space = this.#getSpaceSlug();
return `/${space}/rwallet/api/safe/${this.#chainId}/${this.#safeAddress}`;
return `${getModuleApiBase("rwallet")}/api/safe/${this.#chainId}/${this.#safeAddress}`;
}
async #apiFetch(path: string, opts?: RequestInit): Promise<any> {

View File

@ -11,6 +11,7 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { CrowdSurfLocalFirstClient } from '../local-first-client';
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas';
import { getModuleApiBase } from "../../../shared/url-helpers";
// ── Auth helpers ──
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
@ -624,7 +625,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
this.rankLoading = true;
this.render();
this.bindEvents();
fetch(`/${this.space}/crowdsurf/api/crowdsurf/pair?space=${this.space}`)
fetch(`${getModuleApiBase("crowdsurf")}/api/crowdsurf/pair?space=${this.space}`)
.then(r => r.json())
.then(data => {
if (data.a && data.b) {
@ -697,7 +698,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
}
// Live mode: POST to API
fetch(`/${this.space}/crowdsurf/api/crowdsurf/compare?space=${this.space}`, {
fetch(`${getModuleApiBase("crowdsurf")}/api/crowdsurf/compare?space=${this.space}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ winnerId, loserId }),

View File

@ -10,6 +10,7 @@ import type { TourStep } from '../../../shared/tour-engine';
import './folk-listing';
import './folk-stay-request';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { getModuleApiBase } from "../../../shared/url-helpers";
const BNB_TOUR_STEPS: TourStep[] = [
{ target: '.bnb-search', title: 'Search', message: 'Filter listings by location, type, or economy model.' },
@ -89,9 +90,9 @@ class FolkBnbView extends HTMLElement {
async #loadData() {
try {
const [listingsRes, staysRes, statsRes] = await Promise.all([
fetch(`/${this.#space}/rbnb/api/listings`),
fetch(`/${this.#space}/rbnb/api/stays`),
fetch(`/${this.#space}/rbnb/api/stats`),
fetch(`${getModuleApiBase("rbnb")}/api/listings`),
fetch(`${getModuleApiBase("rbnb")}/api/stays`),
fetch(`${getModuleApiBase("rbnb")}/api/stats`),
]);
if (listingsRes.ok) {
@ -414,7 +415,7 @@ class FolkBnbView extends HTMLElement {
async #handleStayAction(stayId: string, action: string) {
try {
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/${action}`, {
const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
@ -430,7 +431,7 @@ class FolkBnbView extends HTMLElement {
async #handleStayMessage(stayId: string, body: string) {
try {
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/messages`, {
const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, sender_name: 'You' }),

View File

@ -5,6 +5,8 @@
* capacity, location, economy badge, availability dot, endorsement count.
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
const ECONOMY_COLORS: Record<string, { bg: string; fg: string; label: string; icon: string }> = {
gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' },
exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' },
@ -53,7 +55,7 @@ class FolkListing extends HTMLElement {
if (!listingId) return;
try {
const res = await fetch(`/${space}/rbnb/api/listings/${listingId}`);
const res = await fetch(`${getModuleApiBase("rbnb")}/api/listings/${listingId}`);
if (res.ok) {
this.#data = await res.json();
this.#render();

View File

@ -5,6 +5,8 @@
* action buttons (accept/decline/cancel/complete), endorsement prompt.
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
const STATUS_STYLES: Record<string, { bg: string; fg: string; label: string }> = {
pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' },
accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' },
@ -50,7 +52,7 @@ class FolkStayRequest extends HTMLElement {
if (!stayId) return;
try {
const res = await fetch(`/${space}/rbnb/api/stays/${stayId}`);
const res = await fetch(`${getModuleApiBase("rbnb")}/api/stays/${stayId}`);
if (res.ok) {
this.#data = await res.json();
this.#render();

View File

@ -347,7 +347,7 @@ routes.get("/read/:id", async (c) => {
});
// Build the PDF URL relative to this module's mount point
const pdfUrl = `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`;
const pdfUrl = c.get("isSubdomain") ? `/rbooks/api/books/${book.slug}/pdf` : `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`;
const html = renderShell({
title: `${book.title} | rSpace`,

View File

@ -633,7 +633,7 @@ class FolkCartShop extends HTMLElement {
this.shadow.querySelectorAll("[data-action='copy-pay-url']").forEach((el) => {
el.addEventListener("click", () => {
const payId = (el as HTMLElement).dataset.payId;
const url = `${window.location.origin}/${this.space}/rcart/pay/${payId}`;
const url = `${window.location.origin}${this.getApiBase()}/pay/${payId}`;
navigator.clipboard.writeText(url);
(el as HTMLElement).textContent = 'Copied!';
setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000);
@ -644,7 +644,7 @@ class FolkCartShop extends HTMLElement {
this.shadow.querySelectorAll("[data-group-buy-id]").forEach((el) => {
el.addEventListener("click", () => {
const buyId = (el as HTMLElement).dataset.groupBuyId!;
const url = `/${this.space}/rcart/buy/${buyId}`;
const url = `${this.getApiBase()}/buy/${buyId}`;
window.location.href = url;
});
});
@ -1116,7 +1116,7 @@ class FolkCartShop extends HTMLElement {
if (this.groupBuys.length === 0) {
return `<div class="empty">
<p>No group buys yet. Start one from any catalog item to unlock volume pricing together.</p>
<a class="btn btn-primary" href="/${this.space}/rcart/catalog">Browse Catalog</a>
<a class="btn btn-primary" href="${this.getApiBase()}/catalog">Browse Catalog</a>
</div>`;
}
@ -1333,7 +1333,7 @@ class FolkCartShop extends HTMLElement {
<div class="card-meta">${new Date(pay.created_at).toLocaleDateString()}</div>
${pay.status === 'pending' ? `
<div style="margin-top:0.75rem; display:flex; gap:0.5rem">
<a class="btn btn-sm" href="/${this.space}/rcart/pay/${pay.id}" target="_blank" rel="noopener">Open</a>
<a class="btn btn-sm" href="${this.getApiBase()}/pay/${pay.id}" target="_blank" rel="noopener">Open</a>
<button class="btn btn-sm" data-action="copy-pay-url" data-pay-id="${pay.id}">Copy Link</button>
<a class="btn btn-sm" href="${this.getApiBase()}/api/payments/${pay.id}/qr" target="_blank" rel="noopener">QR</a>
</div>` : ''}

View File

@ -66,7 +66,7 @@ class FolkPaymentPage extends HTMLElement {
disconnectedCallback() {
this.stopPolling();
this.walletDiscovery?.stop?.();
window.removeEventListener('message', this.handleTransakMessage);
window.removeEventListener('message', this.handlePaymentMessage);
}
private getApiBase(): string {
@ -116,7 +116,7 @@ class FolkPaymentPage extends HTMLElement {
private async generateQR() {
try {
const QRCode = await import('qrcode');
const payUrl = `${window.location.origin}/${this.space}/rcart/pay/${this.paymentId}`;
const payUrl = `${window.location.origin}${this.getApiBase()}/pay/${this.paymentId}`;
this.qrDataUrl = await QRCode.toDataURL(payUrl, { margin: 2, width: 200 });
} catch { /* QR generation optional */ }
}
@ -528,7 +528,7 @@ class FolkPaymentPage extends HTMLElement {
const explorer = explorerBase[p.chainId] || '';
const cartLink = p.linkedCartId
? `<div style="margin-top:1.25rem"><a href="/${this.space}/rcart/carts" class="btn btn-primary" style="display:inline-block; text-decoration:none; text-align:center;">Return to Cart</a></div>`
? `<div style="margin-top:1.25rem"><a href="${this.getApiBase()}/carts" class="btn btn-primary" style="display:inline-block; text-decoration:none; text-align:center;">Return to Cart</a></div>`
: '';
return `

View File

@ -155,8 +155,8 @@ class FolkPaymentRequest extends HTMLElement {
// Build URLs
const host = window.location.origin;
this.payUrl = `${host}/${this.space}/rcart/pay/${paymentId}`;
this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${paymentId}/qr`;
this.payUrl = `${host}${this.getApiBase()}/pay/${paymentId}`;
this.qrSvgUrl = `${host}${this.getApiBase()}/api/payments/${paymentId}/qr`;
// Generate client-side QR
try {
@ -296,8 +296,8 @@ class FolkPaymentRequest extends HTMLElement {
// Build URLs
const host = window.location.origin;
this.payUrl = `${host}/${this.space}/rcart/pay/${this.generatedPayment.id}`;
this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${this.generatedPayment.id}/qr`;
this.payUrl = `${host}${this.getApiBase()}/pay/${this.generatedPayment.id}`;
this.qrSvgUrl = `${host}${this.getApiBase()}/api/payments/${this.generatedPayment.id}/qr`;
// Generate client-side QR
try {

View File

@ -9,6 +9,7 @@ import { TourEngine } from "../../../shared/tour-engine";
import { ChoicesLocalFirstClient } from "../local-first-client";
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../../../shared/url-helpers";
// ── CrowdSurf types ──
interface CrowdSurfOption {
@ -298,7 +299,7 @@ class FolkChoicesDashboard extends HTMLElement {
${isLive ? `<span class="live-badge"><span class="live-dot"></span>LIVE</span>` : ''}
<div class="create-btns">
${this.lfClient ? `<button class="create-btn" data-action="new-session">+ New Poll</button>` : ''}
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">+ Canvas</a>
<a class="create-btn" href="${rspaceNavUrl(this.space, "rspace")}" title="Open canvas to create choices">+ Canvas</a>
</div>
</div>
@ -472,14 +473,14 @@ class FolkChoicesDashboard extends HTMLElement {
return `<div class="empty">
<div class="empty-icon"></div>
<p>No choices in this space yet.</p>
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
<p>Open the <a href="${rspaceNavUrl(this.space, "rspace")}" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
</div>`;
}
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
return `<div class="grid">
${this.choices.map((ch) => `
<a class="card" data-collab-id="choice:${ch.id}" href="/${this.space}/rspace">
<a class="card" data-collab-id="choice:${ch.id}" href="${rspaceNavUrl(this.space, "rspace")}">
<div class="card-icon">${icons[ch.type] || "☑"}</div>
<div class="card-type">${labels[ch.type] || ch.type}</div>
<h3 class="card-title">${this.esc(ch.title)}</h3>

View File

@ -246,7 +246,7 @@ const DATA_TABS = [
const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id));
function renderDataPage(space: string, activeTab: string) {
function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) {
const isTree = activeTab === "tree";
const body = isTree
? `<folk-content-tree space="${space}"></folk-content-tree>`
@ -266,14 +266,14 @@ function renderDataPage(space: string, activeTab: string) {
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
tabs: [...DATA_TABS],
activeTab,
tabBasePath: process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`,
tabBasePath: isSubdomain ? `/rdata` : `/${space}/rdata`,
});
}
// ── Page routes ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderDataPage(space, "tree"));
return c.html(renderDataPage(space, "tree", c.get("isSubdomain")));
});
routes.get("/:tabId", (c, next) => {
@ -282,9 +282,9 @@ routes.get("/:tabId", (c, next) => {
// Skip API and asset routes — let Hono fall through
if (tabId.startsWith("api") || tabId.includes(".")) return next();
if (!DATA_TAB_IDS.has(tabId as any)) {
return c.redirect(process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, 302);
return c.redirect(c.get("isSubdomain") ? `/rdata` : `/${space}/rdata`, 302);
}
return c.html(renderDataPage(space, tabId));
return c.html(renderDataPage(space, tabId, c.get("isSubdomain")));
});
export const dataModule: RSpaceModule = {

View File

@ -12,6 +12,7 @@ import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { getAccessToken, getUsername } from "../../../lib/rspace-header";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { getModuleApiBase } from "../../../shared/url-helpers";
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
@ -1694,7 +1695,7 @@ class FolkInboxClient extends HTMLElement {
</div>
<div style="text-align:center;margin-top:1.5rem">
<a href="/${this.space}/rinbox/about" class="help-cta">View Full Feature Page &rarr;</a>
<a href="${getModuleApiBase("rinbox")}/about" class="help-cta">View Full Feature Page &rarr;</a>
</div>
</div>
</div>

View File

@ -13,6 +13,7 @@ import { RoomSync, type RoomState, type ParticipantState, type LocationState, ty
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
import { MapPushManager } from "./map-push";
import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy";
import { getModuleApiBase } from "../../../shared/url-helpers";
import "./map-privacy-panel";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
@ -1687,7 +1688,7 @@ class FolkMapViewer extends HTMLElement {
import("./map-share-modal");
const modal = document.createElement("map-share-modal") as any;
modal.id = "share-modal";
modal.url = `${window.location.origin}/${this.space}/rmaps/${this.room}`;
modal.url = `${window.location.origin}${getModuleApiBase("rmaps")}/${this.room}`;
modal.room = this.room;
modal.addEventListener("modal-close", () => { this.showShareModal = false; });
this.shadow.appendChild(modal);

View File

@ -166,7 +166,7 @@ routes.get("/recordings", async (c) => {
routes.get("/recordings/:id", (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
return c.redirect(`/${space}/rmeets/recordings/${id}/overview`);
return c.redirect(c.get("isSubdomain") ? `/rmeets/recordings/${id}/overview` : `/${space}/rmeets/recordings/${id}/overview`);
});
// ── Recording detail with tabs ──

View File

@ -685,7 +685,7 @@ const CRM_TABS = [
const CRM_TAB_IDS = new Set(CRM_TABS.map(t => t.id));
function renderCrm(space: string, activeTab: string) {
function renderCrm(space: string, activeTab: string, isSubdomain: boolean) {
return renderShell({
title: `${space} — CRM | rSpace`,
moduleId: "rnetwork",
@ -698,13 +698,13 @@ function renderCrm(space: string, activeTab: string) {
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
tabs: [...CRM_TABS],
activeTab,
tabBasePath: process.env.NODE_ENV === "production" ? `/rnetwork/crm` : `/${space}/rnetwork/crm`,
tabBasePath: isSubdomain ? `/rnetwork/crm` : `/${space}/rnetwork/crm`,
});
}
routes.get("/crm", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderCrm(space, "pipeline"));
return c.html(renderCrm(space, "pipeline", c.get("isSubdomain")));
});
// Tab subpath routes: /crm/:tabId
@ -712,9 +712,9 @@ routes.get("/crm/:tabId", (c) => {
const space = c.req.param("space") || "demo";
const tabId = c.req.param("tabId");
if (!CRM_TAB_IDS.has(tabId as any)) {
return c.redirect(`/${space}/rnetwork/crm`, 302);
return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 302);
}
return c.html(renderCrm(space, tabId));
return c.html(renderCrm(space, tabId, c.get("isSubdomain")));
});
// ── Page route ──
@ -723,7 +723,7 @@ routes.get("/", (c) => {
const view = c.req.query("view");
if (view === "app") {
return c.redirect(`/${space}/rnetwork/crm`, 301);
return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 301);
}
return c.html(renderShell({

View File

@ -10,6 +10,7 @@
import type { Editor } from '@tiptap/core';
import type { DocumentId } from '../../../shared/local-first/document';
import { getModuleApiBase } from "../../../shared/url-helpers";
interface CommentMessage {
id: string;
@ -731,7 +732,7 @@ class NotesCommentPanel extends HTMLElement {
// Try creating a reminder via rSchedule API (non-demo only)
if (!this.isDemo && this._space) {
try {
const res = await fetch(`/${this._space}/rschedule/api/reminders`, {
const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({
@ -785,7 +786,7 @@ class NotesCommentPanel extends HTMLElement {
// Delete from rSchedule if exists
if (reminderId && !this.isDemo && this._space) {
try {
await fetch(`/${this._space}/rschedule/api/reminders/${reminderId}`, {
await fetch(`${getModuleApiBase("rschedule")}/api/reminders/${reminderId}`, {
method: 'DELETE',
headers: this.authHeaders(),
});

View File

@ -9,6 +9,8 @@
* API-based sources (Notion/Google) use OAuth connections.
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
interface NotebookOption {
id: string;
title: string;
@ -84,7 +86,7 @@ class ImportExportDialog extends HTMLElement {
private async loadConnections() {
try {
const res = await fetch(`/${this.space}/rnotes/api/connections`);
const res = await fetch(`${getModuleApiBase("rnotes")}/api/connections`);
if (res.ok) {
this.connections = await res.json();
}
@ -97,7 +99,7 @@ class ImportExportDialog extends HTMLElement {
if (this.activeSource === 'notion') {
try {
const res = await fetch(`/${this.space}/rnotes/api/import/notion/pages`);
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion/pages`);
if (res.ok) {
const data = await res.json();
this.remotePages = data.pages || [];
@ -105,7 +107,7 @@ class ImportExportDialog extends HTMLElement {
} catch { /* ignore */ }
} else if (this.activeSource === 'google-docs') {
try {
const res = await fetch(`/${this.space}/rnotes/api/import/google-docs/list`);
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs/list`);
if (res.ok) {
const data = await res.json();
this.remotePages = data.docs || [];
@ -145,7 +147,7 @@ class ImportExportDialog extends HTMLElement {
if (this.targetNotebookId) formData.append('notebookId', this.targetNotebookId);
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/import/files`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
@ -177,7 +179,7 @@ class ImportExportDialog extends HTMLElement {
}
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/import/upload`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
@ -201,7 +203,7 @@ class ImportExportDialog extends HTMLElement {
}
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/import/notion`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
@ -228,7 +230,7 @@ class ImportExportDialog extends HTMLElement {
}
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/import/google-docs`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
@ -267,7 +269,7 @@ class ImportExportDialog extends HTMLElement {
try {
if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) {
const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource;
const url = `/${this.space}/rnotes/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`;
const url = `${getModuleApiBase("rnotes")}/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`;
const res = await fetch(url);
if (res.ok) {
@ -288,7 +290,7 @@ class ImportExportDialog extends HTMLElement {
}
} else if (this.activeSource === 'notion') {
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/export/notion`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/notion`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
@ -305,7 +307,7 @@ class ImportExportDialog extends HTMLElement {
}
} else if (this.activeSource === 'google-docs') {
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/export/google-docs`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/google-docs`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
@ -562,7 +564,7 @@ class ImportExportDialog extends HTMLElement {
private async loadSyncStatus(notebookId: string) {
try {
const res = await fetch(`/${this.space}/rnotes/api/sync/status/${notebookId}`);
const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/status/${notebookId}`);
if (res.ok) {
const data = await res.json();
this.syncStatuses = data.statuses || {};
@ -579,7 +581,7 @@ class ImportExportDialog extends HTMLElement {
try {
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/sync/notebook/${this.targetNotebookId}`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/notebook/${this.targetNotebookId}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
@ -624,7 +626,7 @@ class ImportExportDialog extends HTMLElement {
formData.append('source', source);
const token = localStorage.getItem('encryptid_token') || '';
const res = await fetch(`/${this.space}/rnotes/api/sync/upload`, {
const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,

View File

@ -13,6 +13,7 @@ import { pubsDraftSchema, pubsDocId } from '../schemas';
import type { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine';
import { getModuleApiBase } from "../../../shared/url-helpers";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface BookFormat {
@ -445,7 +446,7 @@ export class FolkPubsEditor extends HTMLElement {
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File
</label>
<a class="toolbar-btn btn-zine-gen" href="/${this._spaceSlug}/rpubs/zine" title="AI Zine Generator">
<a class="toolbar-btn btn-zine-gen" href="${getModuleApiBase("rpubs")}/zine" title="AI Zine Generator">
&#128240; Zine
</a>
<button class="toolbar-btn btn-tour-trigger" title="Take a tour" style="opacity:0.7">Tour</button>
@ -766,7 +767,7 @@ export class FolkPubsEditor extends HTMLElement {
const s = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
if (s?.accessToken) authHeaders["Authorization"] = `Bearer ${s.accessToken}`;
} catch {}
const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, {
const res = await fetch(`${getModuleApiBase("rpubs")}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders },
body: JSON.stringify({

View File

@ -12,6 +12,8 @@
* space-slug Current space slug for API calls
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
export class FolkPubsPublishPanel extends HTMLElement {
private _pdfUrl = "";
private _formatId = "";
@ -373,7 +375,7 @@ export class FolkPubsPublishPanel extends HTMLElement {
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, {
const res = await fetch(`${getModuleApiBase("rpubs")}/api/email-pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -403,7 +405,7 @@ export class FolkPubsPublishPanel extends HTMLElement {
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, {
const res = await fetch(`${getModuleApiBase("rpubs")}/api/imposition`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -517,7 +519,7 @@ export class FolkPubsPublishPanel extends HTMLElement {
const { latitude: lat, longitude: lng } = pos.coords;
const res = await fetch(
`/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`,
`${getModuleApiBase("rpubs")}/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`,
);
if (!res.ok) throw new Error("Failed to search printers");
@ -538,7 +540,7 @@ export class FolkPubsPublishPanel extends HTMLElement {
private async placeOrder() {
if (!this._selectedProvider) return;
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, {
const res = await fetch(`${getModuleApiBase("rpubs")}/api/order`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -569,7 +571,7 @@ export class FolkPubsPublishPanel extends HTMLElement {
private async joinBatch() {
if (!this._selectedProvider) return;
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, {
const res = await fetch(`${getModuleApiBase("rpubs")}/api/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({

View File

@ -629,7 +629,7 @@ export class FolkCampaignManager extends HTMLElement {
}
try {
const res = await fetch(`/${this._space}/rsocials/api/campaign/generate`, {
const res = await fetch(`${this.basePath}api/campaign/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brief, platforms, tone, style }),

View File

@ -10,6 +10,7 @@
*/
import { getAccessToken } from '../../../shared/components/rstack-identity';
import { getModuleApiBase } from "../../../shared/url-helpers";
interface DraftData {
id: string;
@ -74,7 +75,7 @@ export class FolkNewsletterManager extends HTMLElement {
}
private apiBase(): string {
return `/${this._space}/rsocials/api/newsletter`;
return `${getModuleApiBase("rsocials")}/api/newsletter`;
}
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {

View File

@ -848,7 +848,7 @@ export class FolkThreadBuilder extends HTMLElement {
postizBtn.textContent = 'Sending...';
(postizBtn as HTMLButtonElement).disabled = true;
try {
const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, {
const res = await fetch(`${this.basePath}api/postiz/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tweets, type: 'draft' }),
@ -1002,7 +1002,7 @@ export class FolkThreadBuilder extends HTMLElement {
roPostizBtn.textContent = 'Sending...';
(roPostizBtn as HTMLButtonElement).disabled = true;
try {
const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, {
const res = await fetch(`${this.basePath}api/postiz/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tweets: t.tweets, type: 'draft' }),

View File

@ -1364,7 +1364,7 @@ routes.post("/api/campaign/wizard", async (c) => {
(d.campaignWizards as any)[wizardId] = wizard;
});
return c.json({ wizardId, url: `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201);
return c.json({ wizardId, url: c.get("isSubdomain") ? `/rsocials/campaign-wizard/${wizardId}` : `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201);
});
routes.get("/api/campaign/wizard/:id", (c) => {

View File

@ -159,9 +159,10 @@ function itemToRow(item: SplatItem): SplatRow {
/**
* Return the subset of SplatRow fields used in list/gallery responses.
*/
function itemToListRow(item: SplatItem, spaceSlug?: string) {
function itemToListRow(item: SplatItem, spaceSlug?: string, isSubdomain?: boolean) {
const effectiveSlug = spaceSlug || item.spaceSlug;
const fileUrl = item.filePath
? `/${spaceSlug || item.spaceSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}`
? (isSubdomain ? `/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}` : `/${effectiveSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}`)
: null;
return {
id: item.id,
@ -229,7 +230,7 @@ routes.get("/api/splats", async (c) => {
// Apply offset and limit
const paged = items.slice(offset, offset + limit);
return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug)) });
return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) });
});
// ── API: Get splat details ──
@ -657,7 +658,7 @@ routes.get("/api/splats/my-history", async (c) => {
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 50);
return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug)) });
return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) });
});
// ── API: Delete splat (owner only) ──
@ -708,7 +709,7 @@ routes.get("/", async (c) => {
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 50);
const rows = items.map(i => itemToListRow(i, spaceSlug));
const rows = items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain")));
const splatsJSON = JSON.stringify(rows);
const html = renderShell({
@ -737,7 +738,7 @@ routes.get("/", async (c) => {
});
// ── Shared viewer page renderer ──
function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string) {
function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string, isSubdomain?: boolean) {
const doc = ensureDoc(dataSpace);
const found = findItem(doc, idOrSlug);
@ -761,7 +762,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
d.items[itemKey].viewCount += 1;
});
const fileUrl = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`;
const fileUrl = isSubdomain ? `/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}` : `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`;
const html = renderShell({
title: `${splat.title} | rSplat`,
@ -852,7 +853,7 @@ routes.get("/:slug", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
const result = renderViewerPage(spaceSlug, dataSpace, slug);
const result = renderViewerPage(spaceSlug, dataSpace, slug, c.get("isSubdomain"));
return c.html(result.html, result.status);
});

View File

@ -711,7 +711,7 @@ routes.post("/api/clickup/import", async (c) => {
const client = new ClickUpClient(accessToken);
const host = c.req.header('host') || 'rspace.online';
const protocol = c.req.header('x-forwarded-proto') || 'https';
const endpoint = `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`;
const endpoint = c.get("isSubdomain") ? `${protocol}://${host}/rtasks/api/clickup/webhook` : `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`;
try {
const wh = await client.createWebhook(
conn.clickup.teamId,

View File

@ -11,6 +11,7 @@ import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
@ -1361,8 +1362,7 @@ GUIDELINES:
const accepted = this._aiGeneratedItems.filter(i => i.accepted);
if (accepted.length === 0) return;
sessionStorage.setItem('rtrips-canvas-export', JSON.stringify(accepted.map(i => ({ type: i.type, props: i.props }))));
const nav = (window as any).__rspaceNavUrl;
window.location.href = (nav ? nav(this.space, 'rspace') : `/${this.space}/rspace`) + '#trip-import';
window.location.href = rspaceNavUrl(this.space, 'rspace') + '#trip-import';
}
private goBack() {

View File

@ -6,6 +6,8 @@
* action buttons (accept/decline/cancel/complete), endorsement prompt.
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
const STATUS_STYLES: Record<string, { bg: string; fg: string; label: string }> = {
pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' },
accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' },
@ -51,7 +53,7 @@ class FolkRentalRequest extends HTMLElement {
if (!rentalId) return;
try {
const res = await fetch(`/${space}/rvnb/api/rentals/${rentalId}`);
const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}`);
if (res.ok) {
this.#data = await res.json();
this.#render();

View File

@ -5,6 +5,8 @@
* owner name + trust badge, sleeps, mileage policy, pickup location, endorsement count.
*/
import { getModuleApiBase } from "../../../shared/url-helpers";
const ECONOMY_COLORS: Record<string, { bg: string; fg: string; label: string; icon: string }> = {
gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' },
exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' },
@ -58,7 +60,7 @@ class FolkVehicleCard extends HTMLElement {
if (!vehicleId) return;
try {
const res = await fetch(`/${space}/rvnb/api/vehicles/${vehicleId}`);
const res = await fetch(`${getModuleApiBase("rvnb")}/api/vehicles/${vehicleId}`);
if (res.ok) {
this.#data = await res.json();
this.#render();

View File

@ -10,6 +10,7 @@ import type { TourStep } from '../../../shared/tour-engine';
import './folk-vehicle-card';
import './folk-rental-request';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { getModuleApiBase } from "../../../shared/url-helpers";
const VNB_TOUR_STEPS: TourStep[] = [
{ target: '.vnb-search', title: 'Search', message: 'Filter by vehicle type, dates, or economy model.' },
@ -94,9 +95,9 @@ class FolkVnbView extends HTMLElement {
async #loadData() {
try {
const [vehiclesRes, rentalsRes, statsRes] = await Promise.all([
fetch(`/${this.#space}/rvnb/api/vehicles`),
fetch(`/${this.#space}/rvnb/api/rentals`),
fetch(`/${this.#space}/rvnb/api/stats`),
fetch(`${getModuleApiBase("rvnb")}/api/vehicles`),
fetch(`${getModuleApiBase("rvnb")}/api/rentals`),
fetch(`${getModuleApiBase("rvnb")}/api/stats`),
]);
if (vehiclesRes.ok) {
@ -467,7 +468,7 @@ class FolkVnbView extends HTMLElement {
async #handleRentalAction(rentalId: string, action: string) {
try {
const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/${action}`, {
const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
@ -483,7 +484,7 @@ class FolkVnbView extends HTMLElement {
async #handleRentalMessage(rentalId: string, body: string) {
try {
const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/messages`, {
const res = await fetch(`${getModuleApiBase("rvnb")}/api/rentals/${rentalId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, sender_name: 'You' }),

View File

@ -390,7 +390,7 @@ routes.post("/api/proposals", async (c) => {
// Notify space members about the new proposal
import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => {
sendSpaceNotification(space_slug, `New Proposal: ${title}`,
`<h3>${title}</h3>${description ? `<p>${description}</p>` : ''}<p><a href="https://rspace.online/${space_slug}/rvote">Vote in rVote</a></p>`
`<h3>${title}</h3>${description ? `<p>${description}</p>` : ''}<p><a href="https://${space_slug}.rspace.online/rvote">Vote in rVote</a></p>`
).catch(() => {});
}).catch(() => {});

View File

@ -651,7 +651,7 @@ function generateFallbackResponse(
for (const m of modules) {
if (q.includes(m.id) || q.includes(m.name.toLowerCase())) {
return `**${m.name}** ${m.icon}${m.description}. You can access it at /${space || "personal"}/${m.id}.`;
return `**${m.name}** ${m.icon}${m.description}. You can access it at /${m.id}.`;
}
}

View File

@ -123,7 +123,7 @@ clickupOAuthRoutes.get('/callback', async (c) => {
});
// Redirect back to rTasks
const redirectUrl = `/${state.space}/rtasks?connected=clickup`;
const redirectUrl = c.get("isSubdomain") ? `/rtasks?connected=clickup` : `/${state.space}/rtasks?connected=clickup`;
return c.redirect(redirectUrl);
});

View File

@ -132,7 +132,7 @@ googleOAuthRoutes.get('/callback', async (c) => {
};
});
const redirectUrl = `/${state.space}/rnotes?connected=google`;
const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=google` : `/${state.space}/rnotes?connected=google`;
return c.redirect(redirectUrl);
});

View File

@ -105,7 +105,7 @@ notionOAuthRoutes.get('/callback', async (c) => {
});
// Redirect back to rNotes
const redirectUrl = `/${state.space}/rnotes?connected=notion`;
const redirectUrl = c.get("isSubdomain") ? `/rnotes?connected=notion` : `/${state.space}/rnotes?connected=notion`;
return c.redirect(redirectUrl);
});

View File

@ -10,7 +10,7 @@
*/
import { isAuthenticated, getAccessToken, getUsername } from "./rstack-identity";
import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers";
import { rspaceNavUrl, getCurrentModule as getModule, getModuleApiBase } from "../url-helpers";
interface SpaceInfo {
slug: string;
@ -1208,7 +1208,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
const savedVal = modules.find(m => m.id === modId)?.settings?.[key] || '';
try {
const token = getAccessToken();
const nbRes = await fetch(`/${space}/rnotes/api/notebooks`, {
const nbRes = await fetch(`${getModuleApiBase("rnotes")}/api/notebooks`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (nbRes.ok) {

View File

@ -9,6 +9,7 @@
*/
import { getSession, getAccessToken } from "./rstack-identity";
import { getModuleApiBase } from "../url-helpers";
interface MemberInfo {
did: string;
@ -95,7 +96,7 @@ export class RStackUserDashboard extends HTMLElement {
async #fetchMembers() {
this.#membersLoading = true;
try {
const res = await fetch(`/${encodeURIComponent(this.space)}/api/space-members`);
const res = await fetch(`${getModuleApiBase("rspace")}/api/space-members`);
if (res.ok) {
const data = await res.json();
this.#members = data.members || [];

View File

@ -118,3 +118,22 @@ export function rspaceNavUrl(space: string, moduleId: string): string {
// Localhost/dev
return `/${space}/${moduleId}`;
}
/**
* Get the API base path for a module, subdomain-aware.
*
* On subdomain: returns `/{moduleId}` (e.g. `/rcart`)
* On localhost: returns `/{space}/{moduleId}` (e.g. `/jeff/rcart`)
*
* Uses regex on the current pathname so it works regardless of routing mode.
*/
export function getModuleApiBase(moduleId: string): string {
const path = window.location.pathname;
const re = new RegExp(`^(\\/[^/]+)?\\/${moduleId}`);
const match = path.match(re);
if (match) return match[0];
// Fallback: subdomain → /{moduleId}, else /{firstSegment}/{moduleId}
if (isSubdomain() || isBareDomain()) return `/${moduleId}`;
const space = path.split("/").filter(Boolean)[0] || "demo";
return `/${space}/${moduleId}`;
}