From 8071b620e14fb681675cfb20bc45d0ddffe9563b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 12:21:26 -0700 Subject: [PATCH] fix(auth): throttle session validation, typed auth-change events, misc fixes - rstack-identity.ts: throttle server session validation to every 5min, add reason detail to all auth-change events (signin/signout/revoked/ refresh/persona-switch), remove redundant location.reload on signout - shell.ts: skip UI side-effects on token refresh, only redirect home on genuine signout/revocation - server.ts: add PUT to CORS allowMethods - folk-inbox-client.ts: pass auth token on mailbox API fetch Co-Authored-By: Claude Opus 4.6 --- .../rinbox/components/folk-inbox-client.ts | 5 +++- shared/components/rstack-identity.ts | 26 ++++++++++++------- src/encryptid/server.ts | 2 +- website/shell.ts | 13 +++++++--- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index 1629781..99ec640 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -182,7 +182,10 @@ class FolkInboxClient extends HTMLElement { private async loadMailboxes() { try { const base = window.location.pathname.replace(/\/$/, ""); - const resp = await fetch(`${base}/api/mailboxes`); + const token = getAccessToken(); + const resp = await fetch(`${base}/api/mailboxes`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (resp.ok) { const data = await resp.json(); this.mailboxes = data.mailboxes || []; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index f1a821a..281a2be 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -394,18 +394,28 @@ export class RStackIdentity extends HTMLElement { async #validateSessionWithServer() { const session = getSession(); if (!session?.accessToken) return; + + // Throttle: skip if validated within the last 5 minutes + const VALIDATE_KEY = "eid_last_validated"; + const VALIDATE_INTERVAL = 5 * 60 * 1000; + const lastValidated = parseInt(localStorage.getItem(VALIDATE_KEY) || "0", 10); + if (Date.now() - lastValidated < VALIDATE_INTERVAL) return; + try { const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); if (!res.ok) { // Session revoked — clear locally and re-render + localStorage.removeItem(VALIDATE_KEY); localStorage.removeItem(SESSION_KEY); localStorage.removeItem("rspace-username"); _removeSessionCookie(); resetDocBridge(); this.#render(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "revoked" } })); + } else { + localStorage.setItem(VALIDATE_KEY, String(Date.now())); } } catch { /* network error — let token expire naturally */ } } @@ -548,7 +558,7 @@ export class RStackIdentity extends HTMLElement { if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) { this.#render(); if (e.key === "encryptid_session") { - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: e.newValue ? "signin" : "signout" } })); // Session cleared from another tab — reload to show logged-out state if (!e.newValue) window.location.reload(); } @@ -595,7 +605,7 @@ export class RStackIdentity extends HTMLElement { const payload = parseJWT(newToken); storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did); this.#render(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "refresh" } })); } } catch { /* offline — keep whatever we have */ } } @@ -684,9 +694,7 @@ export class RStackIdentity extends HTMLElement { if (action === "signout") { clearSession(); resetDocBridge(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); - // Reload so the server re-renders the current rApp in logged-out mode - window.location.reload(); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signout" } })); return; } else if (action === "my-account") { this.showAccountModal(); @@ -715,7 +723,7 @@ export class RStackIdentity extends HTMLElement { clearSession(); resetDocBridge(); this.#render(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "persona-switch" } })); this.showAuthModal(); } else if (action === "remove-persona") { const targetDid = (el as HTMLElement).dataset.did || ""; @@ -879,7 +887,7 @@ export class RStackIdentity extends HTMLElement { storeSession(data.token, data.username || "", data.did || ""); close(); this.#render(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signin" } })); callbacks?.onSuccess?.(); // Auto-redirect to personal space autoResolveSpace(data.token, data.username || ""); @@ -956,7 +964,7 @@ export class RStackIdentity extends HTMLElement { storeSession(data.token, username, data.did || ""); close(); this.#render(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signin" } })); callbacks?.onSuccess?.(); // Show post-signup prompt recommending second device before redirecting this.#showPostSignupPrompt(data.token, username); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 253767a..b7b2da4 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -461,7 +461,7 @@ app.use('*', cors({ } return undefined; }, - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], credentials: true, })); diff --git a/website/shell.ts b/website/shell.ts index 8043899..ee2437b 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -117,13 +117,18 @@ if (spaceSlug) { } // Reload space list when user signs in/out (to show/hide private spaces) -document.addEventListener("auth-change", () => { +document.addEventListener("auth-change", (e) => { + const reason = (e as CustomEvent).detail?.reason; + + // Token refreshes are invisible — no UI side-effects needed + if (reason === "refresh") return; + + // Reload space switcher on state-changing events const spaceSwitcher = document.querySelector("rstack-space-switcher") as any; spaceSwitcher?.reload?.(); - // If signed out, redirect to homepage - const session = localStorage.getItem("encryptid_session"); - if (!session) { + // Only redirect to homepage on genuine sign-out or server revocation + if (reason === "signout" || reason === "revoked") { window.location.href = "/"; } });