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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 14:31:32 -08:00
parent 1a615c29c9
commit a39cf6e1c2
3 changed files with 106 additions and 0 deletions

View File

@ -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<string>();
// ── Static file serving ──
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
@ -1241,6 +1274,42 @@ const server = Bun.serve<WSData>({
// ── 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

View File

@ -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),

View File

@ -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() {