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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 12:21:26 -07:00
parent 32be9d7b94
commit 8071b620e1
4 changed files with 31 additions and 15 deletions

View File

@ -182,7 +182,10 @@ class FolkInboxClient extends HTMLElement {
private async loadMailboxes() { private async loadMailboxes() {
try { try {
const base = window.location.pathname.replace(/\/$/, ""); 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) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
this.mailboxes = data.mailboxes || []; this.mailboxes = data.mailboxes || [];

View File

@ -394,18 +394,28 @@ export class RStackIdentity extends HTMLElement {
async #validateSessionWithServer() { async #validateSessionWithServer() {
const session = getSession(); const session = getSession();
if (!session?.accessToken) return; 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 { try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, { const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${session.accessToken}` }, headers: { Authorization: `Bearer ${session.accessToken}` },
}); });
if (!res.ok) { if (!res.ok) {
// Session revoked — clear locally and re-render // Session revoked — clear locally and re-render
localStorage.removeItem(VALIDATE_KEY);
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username"); localStorage.removeItem("rspace-username");
_removeSessionCookie(); _removeSessionCookie();
resetDocBridge(); resetDocBridge();
this.#render(); 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 */ } } 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) { if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render(); this.#render();
if (e.key === "encryptid_session") { 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 // Session cleared from another tab — reload to show logged-out state
if (!e.newValue) window.location.reload(); if (!e.newValue) window.location.reload();
} }
@ -595,7 +605,7 @@ export class RStackIdentity extends HTMLElement {
const payload = parseJWT(newToken); const payload = parseJWT(newToken);
storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did); storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did);
this.#render(); 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 */ } } catch { /* offline — keep whatever we have */ }
} }
@ -684,9 +694,7 @@ export class RStackIdentity extends HTMLElement {
if (action === "signout") { if (action === "signout") {
clearSession(); clearSession();
resetDocBridge(); resetDocBridge();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signout" } }));
// Reload so the server re-renders the current rApp in logged-out mode
window.location.reload();
return; return;
} else if (action === "my-account") { } else if (action === "my-account") {
this.showAccountModal(); this.showAccountModal();
@ -715,7 +723,7 @@ export class RStackIdentity extends HTMLElement {
clearSession(); clearSession();
resetDocBridge(); resetDocBridge();
this.#render(); 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(); this.showAuthModal();
} else if (action === "remove-persona") { } else if (action === "remove-persona") {
const targetDid = (el as HTMLElement).dataset.did || ""; const targetDid = (el as HTMLElement).dataset.did || "";
@ -879,7 +887,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, data.username || "", data.did || ""); storeSession(data.token, data.username || "", data.did || "");
close(); close();
this.#render(); 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?.(); callbacks?.onSuccess?.();
// Auto-redirect to personal space // Auto-redirect to personal space
autoResolveSpace(data.token, data.username || ""); autoResolveSpace(data.token, data.username || "");
@ -956,7 +964,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, username, data.did || ""); storeSession(data.token, username, data.did || "");
close(); close();
this.#render(); 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?.(); callbacks?.onSuccess?.();
// Show post-signup prompt recommending second device before redirecting // Show post-signup prompt recommending second device before redirecting
this.#showPostSignupPrompt(data.token, username); this.#showPostSignupPrompt(data.token, username);

View File

@ -461,7 +461,7 @@ app.use('*', cors({
} }
return undefined; return undefined;
}, },
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'], allowHeaders: ['Content-Type', 'Authorization'],
credentials: true, credentials: true,
})); }));

View File

@ -117,13 +117,18 @@ if (spaceSlug) {
} }
// Reload space list when user signs in/out (to show/hide private spaces) // 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; const spaceSwitcher = document.querySelector("rstack-space-switcher") as any;
spaceSwitcher?.reload?.(); spaceSwitcher?.reload?.();
// If signed out, redirect to homepage // Only redirect to homepage on genuine sign-out or server revocation
const session = localStorage.getItem("encryptid_session"); if (reason === "signout" || reason === "revoked") {
if (!session) {
window.location.href = "/"; window.location.href = "/";
} }
}); });