From fcc253e7ca34bb08a0eb742b1e5247f74abc8759 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 20:19:07 -0800 Subject: [PATCH 1/7] fix: dark background + emoji favicon for rStack design consistency Standardize to dark background (#0f172a / slate-900) and add emoji data URI favicon matching the rStack ecosystem. Co-Authored-By: Claude Opus 4.6 --- app/globals.css | 11 ++--------- app/layout.tsx | 3 +++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/globals.css b/app/globals.css index 13d40b8..66a5bdc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,15 +3,8 @@ @tailwind utilities; :root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --background: #0f172a; + --foreground: #e2e8f0; } body { diff --git a/app/layout.tsx b/app/layout.tsx index 04bcb72..66d3420 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -16,6 +16,9 @@ const geistMono = localFont({ export const metadata: Metadata = { title: 'rFunds - Threshold-Based Flow Funding', description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms. Create interconnected funding funnels with overflow routing and outcome tracking.', + icons: { + icon: "data:image/svg+xml,πŸ“Š", + }, openGraph: { title: 'rFunds - Threshold-Based Flow Funding', description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.', From bdebf5cda7a5e0916b8be5dfed115b285ef30f83 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 22:20:08 -0800 Subject: [PATCH 2/7] refactor: migrate EncryptID URLs from encryptid.jeffemmett.com to auth.ridentity.online Part of the ridentity.online branding migration. The EncryptID auth server is now accessible at auth.ridentity.online (with the legacy encryptid.jeffemmett.com kept as a backward-compatible alias). Co-Authored-By: Claude Opus 4.6 --- lib/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index f7d9968..fec6844 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -9,7 +9,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { EncryptIDClient } from '@encryptid/sdk/client' -const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com' +const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online' const client = new EncryptIDClient(ENCRYPTID_SERVER) interface AuthState { From 00fc152ed241274fd0f8a2bee929aba8064eb783 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 22:48:03 -0800 Subject: [PATCH 3/7] feat: standardize AppSwitcher and EcosystemFooter across all rApps - Update AppSwitcher with all 26 r*Apps in 8 categories - Add EcosystemFooter component with consistent ecosystem links - Categories: Creating, Planning, Communicating, Deciding, Funding & Commerce, Social & Media, Work & Productivity, Identity & Infrastructure Co-Authored-By: Claude Opus 4.6 --- app/page.tsx | 23 +------- components/AppSwitcher.tsx | 97 +++++++++++++++++++++++++--------- components/EcosystemFooter.tsx | 62 ++++++++++++++++++++++ 3 files changed, 136 insertions(+), 46 deletions(-) create mode 100644 components/EcosystemFooter.tsx diff --git a/app/page.tsx b/app/page.tsx index 21546ba..5cae5f4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import { AppSwitcher } from '@/components/AppSwitcher' +import { EcosystemFooter } from '@/components/EcosystemFooter' export default function Home() { return ( @@ -163,27 +164,7 @@ export default function Home() { {/* Footer */} - + ) } diff --git a/components/AppSwitcher.tsx b/components/AppSwitcher.tsx index 3696bad..2544fca 100644 --- a/components/AppSwitcher.tsx +++ b/components/AppSwitcher.tsx @@ -21,56 +21,91 @@ const MODULES: AppModule[] = [ { id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: 'πŸ“…', description: 'Collaborative scheduling & events', domain: 'rcal.online' }, { id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' }, { id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: 'πŸ—ΊοΈ', description: 'Collaborative real-time mapping', domain: 'rmaps.online' }, - // Discussing & Deciding + // Communicating + { id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: 'πŸ’¬', description: 'Real-time encrypted messaging', domain: 'rchats.online' }, { id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: 'πŸ“¬', description: 'Private group messaging', domain: 'rinbox.online' }, - { id: 'choices', name: 'rChoices', badge: 'rCh', color: 'bg-fuchsia-300', emoji: 'πŸ”€', description: 'Collaborative decision making', domain: 'rchoices.online' }, + { id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: 'βœ‰οΈ', description: 'Community email & newsletters', domain: 'rmail.online' }, + { id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: 'πŸ’­', description: 'Threaded community discussions', domain: 'rforum.online' }, + // Deciding + { id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: 'πŸ”€', description: 'Collaborative decision making', domain: 'rchoices.online' }, { id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: 'πŸ—³οΈ', description: 'Real-time polls & governance', domain: 'rvote.online' }, // Funding & Commerce { id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: 'πŸ’Έ', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' }, { id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: 'πŸ’°', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' }, { id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: 'πŸ›’', description: 'Group commerce & shared shopping', domain: 'rcart.online' }, { id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: 'πŸ”¨', description: 'Live auction platform', domain: 'rauctions.online' }, - // Social & Sharing - { id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' }, - { id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: 'πŸ“', description: 'Collaborative file storage', domain: 'rfiles.online' }, + { id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: 'πŸ‘•', description: 'Community merch & swag store', domain: 'rswag.online' }, + // Social & Media + { id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: 'πŸ“Έ', description: 'Shared community photo albums', domain: 'rphotos.online' }, { id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Group video platform', domain: 'rtube.online' }, + { id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' }, + { id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: 'πŸ“’', description: 'Social media management', domain: 'rsocials.online' }, + { id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: 'πŸ“', description: 'Collaborative file storage', domain: 'rfiles.online' }, { id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: 'πŸ“Š', description: 'Analytics & insights dashboard', domain: 'rdata.online' }, + // Work & Productivity + { id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: 'πŸ’Ό', description: 'Project & task management', domain: 'rwork.online' }, + // Identity & Infrastructure + { id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: 'πŸ”‘', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' }, + { id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: 'πŸ“¦', description: 'Open-source community infrastructure', domain: 'rstack.online' }, ]; const MODULE_CATEGORIES: Record = { - space: 'Creating', - notes: 'Creating', - pubs: 'Creating', - cal: 'Planning', - trips: 'Planning', - inbox: 'Discussing & Deciding', - choices: 'Discussing & Deciding', - vote: 'Discussing & Deciding', - funds: 'Funding & Commerce', - wallet: 'Funding & Commerce', - cart: 'Funding & Commerce', - auctions:'Funding & Commerce', - maps: 'Planning', - network: 'Social & Sharing', - files: 'Social & Sharing', - tube: 'Social & Sharing', - data: 'Social & Sharing', + space: 'Creating', + notes: 'Creating', + pubs: 'Creating', + cal: 'Planning', + trips: 'Planning', + maps: 'Planning', + chats: 'Communicating', + inbox: 'Communicating', + mail: 'Communicating', + forum: 'Communicating', + choices: 'Deciding', + vote: 'Deciding', + funds: 'Funding & Commerce', + wallet: 'Funding & Commerce', + cart: 'Funding & Commerce', + auctions: 'Funding & Commerce', + swag: 'Funding & Commerce', + photos: 'Social & Media', + tube: 'Social & Media', + network: 'Social & Media', + socials: 'Social & Media', + files: 'Social & Media', + data: 'Social & Media', + work: 'Work & Productivity', + ids: 'Identity & Infrastructure', + stack: 'Identity & Infrastructure', }; const CATEGORY_ORDER = [ 'Creating', 'Planning', - 'Discussing & Deciding', + 'Communicating', + 'Deciding', 'Funding & Commerce', - 'Social & Sharing', + 'Social & Media', + 'Work & Productivity', + 'Identity & Infrastructure', ]; +/** Build the URL for a module, using username subdomain if logged in */ +function getModuleUrl(m: AppModule, username: string | null): string { + if (!m.domain) return '#'; + if (username) { + // Generate . URL + return `https://${username}.${m.domain}`; + } + return `https://${m.domain}`; +} + interface AppSwitcherProps { current?: string; } export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { const [open, setOpen] = useState(false); + const [username, setUsername] = useState(null); const ref = useRef(null); useEffect(() => { @@ -83,6 +118,18 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { return () => document.removeEventListener('click', handleClick); }, []); + // Fetch current user's username for subdomain links + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user?.username) { + setUsername(data.user.username); + } + }) + .catch(() => { /* not logged in */ }); + }, []); + const currentMod = MODULES.find((m) => m.id === current); // Group modules by category @@ -140,7 +187,7 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { } transition-colors`} > setOpen(false)} > diff --git a/components/EcosystemFooter.tsx b/components/EcosystemFooter.tsx new file mode 100644 index 0000000..ac513c6 --- /dev/null +++ b/components/EcosystemFooter.tsx @@ -0,0 +1,62 @@ +'use client'; + +const FOOTER_LINKS = [ + { name: 'rSpace', href: 'https://rspace.online' }, + { name: 'rNotes', href: 'https://rnotes.online' }, + { name: 'rPubs', href: 'https://rpubs.online' }, + { name: 'rCal', href: 'https://rcal.online' }, + { name: 'rTrips', href: 'https://rtrips.online' }, + { name: 'rMaps', href: 'https://rmaps.online' }, + { name: 'rChats', href: 'https://rchats.online' }, + { name: 'rInbox', href: 'https://rinbox.online' }, + { name: 'rMail', href: 'https://rmail.online' }, + { name: 'rForum', href: 'https://rforum.online' }, + { name: 'rChoices', href: 'https://rchoices.online' }, + { name: 'rVote', href: 'https://rvote.online' }, + { name: 'rFunds', href: 'https://rfunds.online' }, + { name: 'rWallet', href: 'https://rwallet.online' }, + { name: 'rCart', href: 'https://rcart.online' }, + { name: 'rAuctions', href: 'https://rauctions.online' }, + { name: 'rSwag', href: 'https://rswag.online' }, + { name: 'rPhotos', href: 'https://rphotos.online' }, + { name: 'rTube', href: 'https://rtube.online' }, + { name: 'rNetwork', href: 'https://rnetwork.online' }, + { name: 'rSocials', href: 'https://rsocials.online' }, + { name: 'rFiles', href: 'https://rfiles.online' }, + { name: 'rData', href: 'https://rdata.online' }, + { name: 'rWork', href: 'https://rwork.online' }, + { name: 'rIDs', href: 'https://ridentity.online' }, + { name: 'rStack', href: 'https://rstack.online' }, +]; + +interface EcosystemFooterProps { + current?: string; +} + +export function EcosystemFooter({ current }: EcosystemFooterProps) { + return ( + + ); +} From c8eaa420faa52ec1446c34dfbad9d0a8337516c8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 23:03:11 -0800 Subject: [PATCH 4/7] feat: standardize header with AppSwitcher, SpaceSwitcher, and UserMenu - Replace inline nav/Navbar with shared Header component - Header pattern: AppSwitcher dropdown / SpaceSwitcher / actions / Sign In - SpaceSwitcher and UserMenu work without SDK dependency - Consistent across all r*Apps Co-Authored-By: Claude Opus 4.6 --- app/page.tsx | 22 ++--- components/Header.tsx | 60 ++++++++++++++ components/SpaceSwitcher.tsx | 152 +++++++++++++++++++++++++++++++++++ components/UserMenu.tsx | 65 +++++++++++++++ 4 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 components/Header.tsx create mode 100644 components/SpaceSwitcher.tsx create mode 100644 components/UserMenu.tsx diff --git a/app/page.tsx b/app/page.tsx index 5cae5f4..0773466 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,21 +1,15 @@ import Link from 'next/link' -import { AppSwitcher } from '@/components/AppSwitcher' +import { Header } from '@/components/Header' import { EcosystemFooter } from '@/components/EcosystemFooter' export default function Home() { return (
{/* Nav */} - + + } + /> {/* Hero */}
diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..0d4599d --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { AppSwitcher } from './AppSwitcher'; +import { SpaceSwitcher } from './SpaceSwitcher'; +import { UserMenu } from './UserMenu'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +interface HeaderProps { + /** Which r*App is current (e.g. 'notes', 'vote', 'funds') */ + current?: string; + /** Breadcrumb trail after the switchers */ + breadcrumbs?: BreadcrumbItem[]; + /** Right-side actions (rendered between breadcrumbs and UserMenu) */ + actions?: React.ReactNode; + /** Max width class for the inner container */ + maxWidth?: string; +} + +export function Header({ current = 'notes', breadcrumbs, actions, maxWidth = 'max-w-6xl' }: HeaderProps) { + return ( + + ); +} diff --git a/components/SpaceSwitcher.tsx b/components/SpaceSwitcher.tsx new file mode 100644 index 0000000..33db21a --- /dev/null +++ b/components/SpaceSwitcher.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; + +interface SpaceInfo { + slug: string; + name: string; + icon?: string; + role?: string; +} + +export function SpaceSwitcher() { + const [open, setOpen] = useState(false); + const [spaces, setSpaces] = useState([]); + const [loaded, setLoaded] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, []); + + // Check auth status on mount + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated) setIsAuthenticated(true); + }) + .catch(() => {}); + }, []); + + const loadSpaces = async () => { + if (loaded) return; + try { + const res = await fetch('/api/spaces'); + if (res.ok) { + const data = await res.json(); + setSpaces(data.spaces || []); + } + } catch { + // API not available + } + setLoaded(true); + }; + + const handleOpen = async () => { + const nowOpen = !open; + setOpen(nowOpen); + if (nowOpen && !loaded) { + await loadSpaces(); + } + }; + + const mySpaces = spaces.filter((s) => s.role); + const publicSpaces = spaces.filter((s) => !s.role); + + return ( +
+ + + {open && ( +
+ {!loaded ? ( +
Loading spaces...
+ ) : spaces.length === 0 ? ( + <> +
+ {isAuthenticated ? 'No spaces yet' : 'Sign in to see your spaces'} +
+ setOpen(false)} + > + + Create new space + + + ) : ( + <> + {mySpaces.length > 0 && ( + <> +
+ Your spaces +
+ {mySpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + {s.role && ( + + {s.role} + + )} + + ))} + + )} + + {publicSpaces.length > 0 && ( + <> + {mySpaces.length > 0 &&
} +
+ Public spaces +
+ {publicSpaces.map((s) => ( + setOpen(false)} + > + {s.icon || '🌐'} + {s.name} + + ))} + + )} + + + )} +
+ ); +} diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx new file mode 100644 index 0000000..3a31e75 --- /dev/null +++ b/components/UserMenu.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface UserInfo { + username?: string; + did?: string; +} + +export function UserMenu() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user) { + setUser(data.user); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ ); + } + + if (!user) { + return ( + + Sign In + + ); + } + + const displayName = user.username || (user.did ? `${user.did.slice(0, 12)}...` : 'User'); + + return ( +
+
+
+ {(user.username || 'U')[0].toUpperCase()} +
+ {displayName} +
+ +
+ ); +} From 3323f7c9bf6873ca304d36a4b8ff27baab9b2507 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 23:09:15 -0800 Subject: [PATCH 5/7] fix: nest Sign Out under username dropdown in UserMenu Co-Authored-By: Claude Opus 4.6 --- components/UserMenu.tsx | 56 ++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index 3a31e75..e891244 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface UserInfo { username?: string; @@ -10,6 +10,8 @@ interface UserInfo { export function UserMenu() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const ref = useRef(null); useEffect(() => { fetch('/api/me') @@ -23,6 +25,16 @@ export function UserMenu() { .finally(() => setLoading(false)); }, []); + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, []); + if (loading) { return (
@@ -43,23 +55,39 @@ export function UserMenu() { const displayName = user.username || (user.did ? `${user.did.slice(0, 12)}...` : 'User'); return ( -
-
-
+
+
- + + {open && ( +
+
+
{displayName}
+ {user.did && ( +
{user.did}
+ )} +
+ +
+ )}
); } From 5ff8b8a83f70698dc59592f798c7ee58da423ddc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 23:18:57 -0800 Subject: [PATCH 6/7] fix: space links use subdomain URLs instead of rspace.online path Space links now go to . (e.g., myspace.rfunds.online) instead of rspace.online/. Domain derived from window.location when not explicitly provided. Co-Authored-By: Claude Opus 4.6 --- components/SpaceSwitcher.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/components/SpaceSwitcher.tsx b/components/SpaceSwitcher.tsx index 33db21a..f5a9450 100644 --- a/components/SpaceSwitcher.tsx +++ b/components/SpaceSwitcher.tsx @@ -9,13 +9,23 @@ interface SpaceInfo { role?: string; } -export function SpaceSwitcher() { +interface SpaceSwitcherProps { + /** Current app domain, e.g. 'rfunds.online'. Space links become . */ + domain?: string; +} + +export function SpaceSwitcher({ domain }: SpaceSwitcherProps) { const [open, setOpen] = useState(false); const [spaces, setSpaces] = useState([]); const [loaded, setLoaded] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); const ref = useRef(null); + // Derive domain from window.location if not provided + const appDomain = domain || (typeof window !== 'undefined' + ? window.location.hostname.split('.').slice(-2).join('.') + : 'rspace.online'); + useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { @@ -58,6 +68,9 @@ export function SpaceSwitcher() { } }; + /** Build URL for a space: . */ + const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`; + const mySpaces = spaces.filter((s) => s.role); const publicSpaces = spaces.filter((s) => !s.role); @@ -99,7 +112,7 @@ export function SpaceSwitcher() { {mySpaces.map((s) => ( setOpen(false)} > @@ -124,7 +137,7 @@ export function SpaceSwitcher() { {publicSpaces.map((s) => ( setOpen(false)} > From 6c23811516dac4e951d2bb6beccd5be5729760f2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 13:22:49 -0800 Subject: [PATCH 7/7] feat: add space subdomain routing and ownership support - Traefik wildcard HostRegexp for .rfunds.online subdomains - Middleware subdomain extraction and path rewriting - Provision endpoint with owner_did acknowledgement Co-Authored-By: Claude Opus 4.6 --- app/api/internal/provision/route.ts | 21 +++++++++++ docker-compose.yml | 2 +- middleware.ts | 56 ++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 app/api/internal/provision/route.ts diff --git a/app/api/internal/provision/route.ts b/app/api/internal/provision/route.ts new file mode 100644 index 0000000..e6ae7ee --- /dev/null +++ b/app/api/internal/provision/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +/** + * Internal provision endpoint β€” called by rSpace Registry when activating + * this app for a space. No auth required (only reachable from Docker network). + * + * Payload: { space, description, admin_email, public, owner_did } + * The owner_did identifies who registered the space via the registry. + * + * rfunds has no server-side database β€” spaces are managed client-side. + * This endpoint acknowledges the provisioning request. + */ +export async function POST(request: Request) { + const body = await request.json(); + const space: string = body.space?.trim(); + if (!space) { + return NextResponse.json({ error: "Missing space name" }, { status: 400 }); + } + const ownerDid: string = body.owner_did || ""; + return NextResponse.json({ status: "ok", space, owner_did: ownerDid, message: "rfunds space acknowledged" }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 366c6cf..2bdf312 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: restart: unless-stopped labels: - "traefik.enable=true" - - "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`)" + - "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rfunds.online`)" - "traefik.http.services.rfunds.loadbalancer.server.port=3000" healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] diff --git a/middleware.ts b/middleware.ts index 8c3ddbd..83127f1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,23 +1,55 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +import { isDemoRequest } from '@encryptid/sdk/server/nextjs' /** - * Middleware to protect /space routes. + * Middleware to handle subdomain-based space routing and protect /space routes. * - * Client-side auth enforcement: the space page itself checks auth state via - * Zustand store. This middleware adds a cookie-based check for server-rendered - * requests β€” if no encryptid_token cookie is present on /space, redirect to - * the home page with a login hint. + * Subdomain routing: + * - rfunds.online -> home/landing page + * - www.rfunds.online -> home/landing page + * - .rfunds.online -> rewrite to /s/ * - * Note: Since rfunds uses client-side Zustand persistence (localStorage), - * the primary auth gate is in the SpacePage component itself. This middleware - * serves as an additional layer for direct URL access. + * Auth protection: + * - /space routes require auth (cookie or Bearer token) + * - Demo spaces (ENCRYPTID_DEMO_SPACES env var) bypass auth + * + * Also handles localhost for development. */ export function middleware(request: NextRequest) { + const url = request.nextUrl.clone() + const hostname = request.headers.get('host') || '' const { pathname } = request.nextUrl + // --- Subdomain routing --- + let subdomain: string | null = null + + // Match production: .rfunds.online + const match = hostname.match(/^([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])\.\w+\.online/) + if (match && match[1] !== 'www') { + subdomain = match[1] + } else if (hostname.includes('localhost')) { + // Development: .localhost:port + const parts = hostname.split('.localhost')[0].split('.') + if (parts.length > 0 && parts[0] !== 'localhost') { + subdomain = parts[parts.length - 1] + } + } + + // If we have a subdomain, rewrite root path to space page + if (subdomain && subdomain.length > 0 && pathname === '/') { + url.pathname = `/s/${subdomain}` + return NextResponse.rewrite(url) + } + + // --- Auth protection for /space routes --- // Only protect /space routes (not /tbff which is a public demo) if (pathname.startsWith('/space')) { + // Demo spaces get anonymous access β€” SDK provides synthetic claims + if (isDemoRequest(request)) { + return NextResponse.next() + } + // Check for auth token in cookie (set by client after login) const token = request.cookies.get('encryptid_token')?.value @@ -26,10 +58,6 @@ export function middleware(request: NextRequest) { const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null if (!token && !bearerToken) { - // No auth β€” redirect to home with login hint - // The client-side auth store is the primary gate, but this catches - // direct navigation before hydration - const url = request.nextUrl.clone() url.pathname = '/' url.searchParams.set('login', 'required') url.searchParams.set('return', pathname) @@ -41,5 +69,7 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ['/space/:path*'], + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\..*|api).*)', + ], }