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:
parent
1a615c29c9
commit
a39cf6e1c2
|
|
@ -1074,6 +1074,39 @@ function getSubdomain(host: string | null): string | null {
|
||||||
return 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 ──
|
// ── Static file serving ──
|
||||||
function getContentType(path: string): string {
|
function getContentType(path: string): string {
|
||||||
if (path.endsWith(".html")) return "text/html";
|
if (path.endsWith(".html")) return "text/html";
|
||||||
|
|
@ -1241,6 +1274,42 @@ const server = Bun.serve<WSData>({
|
||||||
|
|
||||||
// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
|
// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
|
||||||
if (subdomain) {
|
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);
|
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
// Root: show space dashboard
|
// Root: show space dashboard
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
// ── Tab bar / Layer system initialization ──
|
||||||
// Tabs persist in localStorage so they survive full-page navigations.
|
// Tabs persist in localStorage so they survive full-page navigations.
|
||||||
// When a user opens a new rApp (via the app switcher or tab-add),
|
// When a user opens a new rApp (via the app switcher or tab-add),
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,14 @@ export class RStackIdentity extends HTMLElement {
|
||||||
this.#refreshIfNeeded();
|
this.#refreshIfNeeded();
|
||||||
this.#render();
|
this.#render();
|
||||||
this.#startNotifPolling();
|
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() {
|
disconnectedCallback() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue