From a39cf6e1c24667957394edb32af9817f529c6271 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 1 Mar 2026 14:31:32 -0800 Subject: [PATCH] feat: auto-provision personal spaces on first visit + redirect logged-in users from demo Server-side middleware creates the user's personal space when they visit {username}.rspace.online for the first time (token in cookie, verified once). Client-side redirect sends logged-in demo users to their personal space. "Try Demo" button sets a sessionStorage flag to bypass the redirect. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 69 ++++++++++++++++++++++++++++ server/shell.ts | 29 ++++++++++++ shared/components/rstack-identity.ts | 8 ++++ 3 files changed, 106 insertions(+) diff --git a/server/index.ts b/server/index.ts index ca7f89d..e71441b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1074,6 +1074,39 @@ function getSubdomain(host: string | null): string | null { return null; } +// ── Auto-provision helpers ── + +/** Extract JWT from Authorization header, then eid_token cookie, then encryptid_token cookie */ +function extractTokenFromRequest(req: Request): string | null { + const auth = req.headers.get("authorization"); + if (auth?.startsWith("Bearer ")) return auth.slice(7); + const cookie = req.headers.get("cookie"); + if (cookie) { + const eidMatch = cookie.match(/(?:^|;\s*)eid_token=([^;]*)/); + if (eidMatch) return decodeURIComponent(eidMatch[1]); + const encMatch = cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/); + if (encMatch) return decodeURIComponent(encMatch[1]); + } + return null; +} + +/** Base64-decode JWT payload to extract username (cheap pre-check, no crypto) */ +function decodeJWTUsername(token: string): string | null { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + const payload = JSON.parse(atob(b64 + pad)); + return payload.username?.toLowerCase() || null; + } catch { + return null; + } +} + +/** Tracks slugs currently being provisioned to prevent concurrent double-creation */ +const provisioningInProgress = new Set(); + // ── Static file serving ── function getContentType(path: string): string { if (path.endsWith(".html")) return "text/html"; @@ -1241,6 +1274,42 @@ const server = Bun.serve({ // ── Subdomain routing: {space}.rspace.online/{moduleId}/... ── if (subdomain) { + // ── Auto-provision personal space on first visit ── + // Fast path: communityExists is an in-memory Map check for cached spaces. + if (!(await communityExists(subdomain))) { + const token = extractTokenFromRequest(req); + if (token) { + const jwtUsername = decodeJWTUsername(token); + if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) { + provisioningInProgress.add(subdomain); + try { + const claims = await verifyEncryptIDToken(token); + const username = claims.username?.toLowerCase(); + if (username === subdomain && !(await communityExists(subdomain))) { + await createCommunity( + `${claims.username}'s Space`, + subdomain, + claims.sub, + "members_only", + ); + for (const mod of getAllModules()) { + if (mod.onSpaceCreate) { + try { await mod.onSpaceCreate(subdomain); } catch (e) { + console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e); + } + } + } + console.log(`[AutoProvision] Created personal space on visit: ${subdomain}`); + } + } catch (e) { + console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e); + } finally { + provisioningInProgress.delete(subdomain); + } + } + } + } + const pathSegments = url.pathname.split("/").filter(Boolean); // Root: show space dashboard diff --git a/server/shell.ts b/server/shell.ts index 9d48db8..235bc0d 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -173,6 +173,35 @@ export function renderShell(opts: ShellOptions): string { }); })(); + // ── Redirect logged-in users from demo to personal space ── + (function() { + var slug = '${escapeAttr(spaceSlug)}'; + var modId = '${escapeAttr(moduleId)}'; + if (slug !== 'demo') return; + if (sessionStorage.getItem('rspace_stay_demo')) return; + try { + var raw = localStorage.getItem('encryptid_session'); + if (!raw) return; + var session = JSON.parse(raw); + var username = session && session.claims && session.claims.username; + if (!username) return; + username = username.toLowerCase(); + var host = window.location.host.split(':')[0]; + // Already on the user's personal subdomain — skip + if (host === username + '.rspace.online') return; + var target = window.location.protocol + '//' + username + '.rspace.online/' + modId; + window.location.replace(target); + } catch(e) {} + })(); + + // ── "Try Demo" dismiss: set sessionStorage flag so redirect doesn't fire ── + (function() { + var btn = document.querySelector('.rstack-header__demo-btn'); + if (btn) btn.addEventListener('click', function() { + try { sessionStorage.setItem('rspace_stay_demo', '1'); } catch(e) {} + }); + })(); + // ── Tab bar / Layer system initialization ── // Tabs persist in localStorage so they survive full-page navigations. // When a user opens a new rApp (via the app switcher or tab-add), diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 121539c..fe3d06c 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -275,6 +275,14 @@ export class RStackIdentity extends HTMLElement { this.#refreshIfNeeded(); this.#render(); this.#startNotifPolling(); + + // Belt-and-suspenders: if a session already exists on page load, + // ensure the user's personal space is provisioned (catches edge + // cases like iframe embedding or direct navigation). + const session = getSession(); + if (session?.accessToken && session.claims.username) { + autoResolveSpace(session.accessToken, session.claims.username); + } } disconnectedCallback() {