From e7ce57ce0b942a34bc2762103a48527e22648255 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 15:47:47 -0800 Subject: [PATCH] feat: auto-route users to personal/demo space + landing overlay - Anon users visiting any rApp (standalone or unified) land on demo space - Logged-in users auto-redirect to personal space (auto-provisioned) - POST /api/spaces/auto-provision creates personal space on first visit - Standalone domains support / path prefix (rpubs.online/jeff) - rspace.online/ redirects to /demo/canvas (app-first experience) - Quarter-screen welcome overlay on demo space for first-time visitors - Full landing page moved to /about - Auth flow triggers auto-space-resolution on sign-in/register - Demo space seeded with shapes for all 22 rApps Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 73 ++++++- server/seed-demo.ts | 281 +++++++++++++++++++++++++++ server/shell.ts | 152 +++++++++++++++ shared/components/rstack-identity.ts | 41 ++++ website/index.html | 34 +++- 5 files changed, 573 insertions(+), 8 deletions(-) diff --git a/server/index.ts b/server/index.ts index d15b785..c7c7433 100644 --- a/server/index.ts +++ b/server/index.ts @@ -423,6 +423,48 @@ app.get("/api/modules", (c) => { return c.json({ modules: getModuleInfoList() }); }); +// ── Auto-provision personal space ── +app.post("/api/spaces/auto-provision", 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 or expired token" }, 401); + } + + const username = claims.username?.toLowerCase(); + if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) { + return c.json({ error: "Username not suitable for space slug" }, 400); + } + + if (await communityExists(username)) { + return c.json({ status: "exists", slug: username }); + } + + await createCommunity( + `${claims.username}'s Space`, + username, + claims.sub, + "authenticated", + ); + + for (const mod of getAllModules()) { + if (mod.onSpaceCreate) { + try { + await mod.onSpaceCreate(username); + } catch (e) { + console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e); + } + } + } + + console.log(`[AutoProvision] Created personal space: ${username}`); + return c.json({ status: "created", slug: username }, 201); +}); + // ── Mount module routes under /:space/:moduleId ── for (const mod of getAllModules()) { app.route(`/:space/${mod.id}`, mod.routes); @@ -430,8 +472,11 @@ for (const mod of getAllModules()) { // ── Page routes ── -// Landing page: rspace.online/ -app.get("/", async (c) => { +// Landing page: rspace.online/ → redirect to demo canvas (overlay shows there) +app.get("/", (c) => c.redirect("/demo/canvas", 302)); + +// About/info page (full landing content) +app.get("/about", async (c) => { const file = Bun.file(resolve(DIST_DIR, "index.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); @@ -633,9 +678,27 @@ const server = Bun.serve({ if (staticResponse) return staticResponse; } - // Rewrite: / → /demo/{moduleId}, /foo → /demo/{moduleId}/foo - const suffix = url.pathname === "/" ? "" : url.pathname; - const rewrittenPath = `/demo/${standaloneModuleId}${suffix}`; + // Determine space from URL path: /{space} prefix on standalone domains + // / → /demo/{moduleId} (anon default) + // /jeff → /jeff/{moduleId} (personal space) + // /api/... → /demo/{moduleId}/api/... (module API) + const pathParts = url.pathname.split("/").filter(Boolean); + let space = "demo"; + let suffix = ""; + + if ( + pathParts.length > 0 && + !pathParts[0].includes(".") && + pathParts[0] !== "api" && + pathParts[0] !== "ws" + ) { + space = pathParts[0]; + suffix = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : ""; + } else if (url.pathname !== "/") { + suffix = url.pathname; + } + + const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); diff --git a/server/seed-demo.ts b/server/seed-demo.ts index b875999..461dd1b 100644 --- a/server/seed-demo.ts +++ b/server/seed-demo.ts @@ -482,6 +482,287 @@ const DEMO_SHAPES: Record[] = [ targetId: "demo-ledger-cred", color: "#059669", }, + + // ─── rFiles: Shared Documents ────────────────────────────── + { + id: "demo-file-permits", + type: "folk-file", + x: 1550, y: 900, width: 280, height: 80, rotation: 0, + fileName: "Swiss Hiking Permits.pdf", + fileSize: "2.4 MB", + mimeType: "application/pdf", + uploadedBy: "Priya", + uploadedAt: "2026-06-28", + tags: ["permits", "switzerland", "logistics"], + }, + { + id: "demo-file-insurance", + type: "folk-file", + x: 1550, y: 1000, width: 280, height: 80, rotation: 0, + fileName: "Travel Insurance Policy.pdf", + fileSize: "1.1 MB", + mimeType: "application/pdf", + uploadedBy: "Omar", + uploadedAt: "2026-06-25", + tags: ["insurance", "emergency", "documents"], + }, + { + id: "demo-file-topo", + type: "folk-file", + x: 1550, y: 1100, width: 280, height: 80, rotation: 0, + fileName: "Tre Cime Topo Map.gpx", + fileSize: "340 KB", + mimeType: "application/gpx+xml", + uploadedBy: "Liam", + uploadedAt: "2026-07-01", + tags: ["maps", "hiking", "dolomites"], + }, + + // ─── rForum: Discussion Threads ──────────────────────────── + { + id: "demo-forum-weather", + type: "folk-forum-thread", + x: 1870, y: 900, width: 320, height: 160, rotation: 0, + threadTitle: "Weather Contingency Plan", + author: "Omar", + createdAt: "2026-06-30", + replyCount: 8, + lastReply: "Maya — 2 hours ago", + preview: "What's our plan if the Matterhorn trek gets rained out? I found a cheese museum in Zermatt as backup...", + tags: ["planning", "weather"], + }, + { + id: "demo-forum-transport", + type: "folk-forum-thread", + x: 1870, y: 1080, width: 320, height: 140, rotation: 0, + threadTitle: "Train vs Rental Car — Dolomites Transfer", + author: "Liam", + createdAt: "2026-06-28", + replyCount: 5, + lastReply: "Priya — yesterday", + preview: "The Bernina Express is scenic but 6 hours. Rental car is 3.5 hours. Thoughts?", + tags: ["transport", "logistics"], + }, + + // ─── rBooks: Shared Reading ──────────────────────────────── + { + id: "demo-book-alpine", + type: "folk-book", + x: 2350, y: 50, width: 280, height: 200, rotation: 0, + bookTitle: "The Alps: A Human History", + author: "Stephen O'Shea", + coverColor: "#7c3aed", + pageCount: 320, + currentPage: 145, + readers: ["Maya", "Liam"], + status: "reading", + }, + { + id: "demo-book-wild", + type: "folk-book", + x: 2350, y: 280, width: 280, height: 200, rotation: 0, + bookTitle: "Wild: A Journey from Lost to Found", + author: "Cheryl Strayed", + coverColor: "#059669", + pageCount: 315, + currentPage: 315, + readers: ["Priya"], + status: "finished", + }, + + // ─── rPubs: Published Artifacts ──────────────────────────── + { + id: "demo-pub-zine", + type: "folk-pub", + x: 2350, y: 520, width: 300, height: 180, rotation: 0, + pubTitle: "Alpine Explorer Zine", + pubType: "zine", + creator: "Maya", + format: "A5 risograph", + status: "in-production", + copies: 50, + price: 12.00, + currency: "EUR", + description: "Photo zine documenting the Alpine Explorer 2026 trip. Risograph printed on recycled paper.", + }, + + // ─── rSwag: Merchandise ──────────────────────────────────── + { + id: "demo-swag-tee", + type: "folk-swag", + x: 2350, y: 730, width: 280, height: 120, rotation: 0, + swagTitle: "Alpine Explorer 2026 Tee", + swagType: "t-shirt", + designer: "Liam", + sizes: ["S", "M", "L", "XL"], + price: 28.00, + currency: "EUR", + status: "available", + orderCount: 12, + }, + + // ─── rProviders: Local Production ────────────────────────── + { + id: "demo-provider-risograph", + type: "folk-provider", + x: 2350, y: 880, width: 300, height: 160, rotation: 0, + providerName: "Chamonix Print Collective", + location: "Chamonix, France", + capabilities: ["risograph", "screen-print", "letterpress"], + substrates: ["recycled paper", "card stock", "cotton"], + turnaround: "5-7 days", + rating: 4.8, + ordersFulfilled: 127, + }, + + // ─── rWork: Task Board ───────────────────────────────────── + { + id: "demo-work-board", + type: "folk-work-board", + x: 750, y: 1350, width: 500, height: 280, rotation: 0, + boardTitle: "Trip Preparation Tasks", + columns: [ + { + name: "To Do", + tasks: [ + { title: "Book paragliding (2 remaining spots)", assignee: "Liam", priority: "high" }, + { title: "Buy trekking poles", assignee: "Maya", priority: "medium" }, + ], + }, + { + name: "In Progress", + tasks: [ + { title: "Research Italian drone regulations", assignee: "Liam", priority: "high" }, + { title: "First aid training refresher", assignee: "Omar", priority: "medium" }, + ], + }, + { + name: "Done", + tasks: [ + { title: "Book hut reservations", assignee: "Priya", priority: "high" }, + { title: "Get travel insurance", assignee: "Omar", priority: "high" }, + { title: "Break in hiking boots", assignee: "Maya", priority: "medium" }, + ], + }, + ], + }, + + // ─── rCal: Shared Calendar ───────────────────────────────── + { + id: "demo-cal-events", + type: "folk-calendar", + x: 50, y: 1350, width: 350, height: 250, rotation: 0, + calTitle: "Alpine Explorer 2026", + month: "July 2026", + events: [ + { date: "Jul 6", title: "Fly to Geneva", color: "#3b82f6" }, + { date: "Jul 7", title: "Lac Blanc Hike", color: "#22c55e" }, + { date: "Jul 8", title: "Via Ferrata", color: "#ef4444" }, + { date: "Jul 10", title: "Train to Zermatt", color: "#3b82f6" }, + { date: "Jul 13", title: "Paragliding", color: "#ef4444" }, + { date: "Jul 14", title: "Transfer to Dolomites", color: "#3b82f6" }, + { date: "Jul 15", title: "Tre Cime Loop", color: "#22c55e" }, + { date: "Jul 18", title: "Cooking Class", color: "#f59e0b" }, + { date: "Jul 20", title: "Fly Home", color: "#3b82f6" }, + ], + }, + + // ─── rNetwork: Contact Graph ─────────────────────────────── + { + id: "demo-network-graph", + type: "folk-network", + x: 1300, y: 1350, width: 400, height: 280, rotation: 0, + networkTitle: "Trip Contacts", + nodes: [ + { id: "maya", label: "Maya", role: "organizer" }, + { id: "liam", label: "Liam", role: "photographer" }, + { id: "priya", label: "Priya", role: "logistics" }, + { id: "omar", label: "Omar", role: "safety" }, + { id: "vrony", label: "Chez Vrony", role: "restaurant" }, + { id: "rega", label: "REGA Rescue", role: "emergency" }, + { id: "locatelli", label: "Rif. Locatelli", role: "hut" }, + ], + edges: [ + { from: "maya", to: "liam" }, + { from: "maya", to: "priya" }, + { from: "maya", to: "omar" }, + { from: "priya", to: "locatelli" }, + { from: "omar", to: "rega" }, + { from: "liam", to: "vrony" }, + ], + }, + + // ─── rTube: Shared Videos ────────────────────────────────── + { + id: "demo-tube-vlog", + type: "folk-video", + x: 2680, y: 50, width: 300, height: 180, rotation: 0, + videoTitle: "Lac Blanc Sunrise — Test Footage", + duration: "3:42", + creator: "Liam", + uploadedAt: "2026-07-08", + views: 24, + thumbnail: "sunrise-lacblanc", + }, + + // ─── rInbox: Group Messages ──────────────────────────────── + { + id: "demo-inbox-msg", + type: "folk-inbox", + x: 2680, y: 260, width: 300, height: 160, rotation: 0, + inboxTitle: "Trip Group Chat", + messages: [ + { from: "Maya", text: "Don't forget passports tomorrow!", time: "10:32 AM" }, + { from: "Liam", text: "Drone batteries charging. All 3 ready.", time: "10:45 AM" }, + { from: "Omar", text: "First aid kit packed. Added altitude sickness meds.", time: "11:02 AM" }, + { from: "Priya", text: "Locatelli confirmed our reservation!", time: "11:15 AM" }, + ], + }, + + // ─── rData: Trip Analytics ───────────────────────────────── + { + id: "demo-data-dashboard", + type: "folk-dashboard", + x: 2680, y: 450, width: 320, height: 220, rotation: 0, + dashTitle: "Trip Statistics", + metrics: [ + { label: "Total Budget", value: "€4,000", trend: "neutral" }, + { label: "Spent", value: "€1,203", trend: "up" }, + { label: "Remaining", value: "€2,797", trend: "down" }, + { label: "Tasks Done", value: "3/7", trend: "up" }, + { label: "Hike Distance", value: "~85 km", trend: "neutral" }, + { label: "Peak Altitude", value: "3,842m", trend: "neutral" }, + ], + }, + + // ─── rChoices: Decision Matrix ───────────────────────────── + { + id: "demo-choices-camera", + type: "folk-choice-matrix", + x: 2680, y: 700, width: 320, height: 200, rotation: 0, + choiceTitle: "Camera Gear Decision", + options: [ + { name: "DJI Mini 4 Pro", score: 8.5, criteria: { weight: 9, quality: 8, price: 7, battery: 10 } }, + { name: "GoPro Hero 12", score: 7.2, criteria: { weight: 10, quality: 6, price: 8, battery: 5 } }, + { name: "Sony A7C II", score: 7.8, criteria: { weight: 4, quality: 10, price: 5, battery: 8 } }, + ], + decidedBy: "Liam", + status: "decided", + winner: "DJI Mini 4 Pro", + }, + + // ─── rSplat: 3D Captures ─────────────────────────────────── + { + id: "demo-splat-matterhorn", + type: "folk-splat", + x: 2680, y: 930, width: 300, height: 160, rotation: 0, + splatTitle: "Matterhorn Base Camp — 3D Scan", + pointCount: "2.4M", + capturedBy: "Liam", + capturedAt: "2026-07-12", + fileSize: "48 MB", + status: "processing", + }, ]; /** diff --git a/server/shell.ts b/server/shell.ts index 529b61f..54c4afe 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -59,6 +59,7 @@ export function renderShell(opts: ShellOptions): string { ${styles} ${head} +
@@ -79,11 +80,60 @@ export function renderShell(opts: ShellOptions): string {
${body}
+ + ${renderWelcomeOverlay()} +