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;
|
||||
}
|
||||
|
||||
// ── 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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue