diff --git a/src/backend/services/homepage/PuterHomepageService.test.ts b/src/backend/services/homepage/PuterHomepageService.test.ts
index 503167992..699039956 100644
--- a/src/backend/services/homepage/PuterHomepageService.test.ts
+++ b/src/backend/services/homepage/PuterHomepageService.test.ts
@@ -401,3 +401,150 @@ describe('PuterHomepageService — head metadata', () => {
expect(html).toContain('');
});
});
+
+describe('PuterHomepageService — pre-paint session gate', () => {
+ /** The gate's inline script, extracted so it can be run against a fake DOM. */
+ const gateScript = (html: string): string => {
+ const head = html.slice(0, html.indexOf(''));
+ const block = [...head.matchAll(/`;
+
/**
* Serves the root HTML shell that bootstraps the Puter GUI.
*
@@ -271,6 +322,7 @@ export class PuterHomepageService extends PuterService {
${e(title)}
+ ${SESSION_GATE}
${event.prependHeadContent}
diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js
index 00888a6c1..d22b6239f 100644
--- a/src/gui/src/initgui.js
+++ b/src/gui/src/initgui.js
@@ -1010,6 +1010,40 @@ function authErrorDisplayMessage() {
return i18n('auth_error_generic', [], false);
}
+/**
+ * Settles the shell's server-rendered anonymous markup — the marketing
+ * homepage, an `/app/` landing — once the client knows whether it has a
+ * session.
+ *
+ * The shell renders that markup from the session cookie alone, and a browser
+ * drops that cookie on quit while our localStorage token lives on. So a
+ * returning user gets served it, and `PuterHomepageService`'s `` gate
+ * hides it pre-paint on the strength of the stored token (the
+ * `has-stored-session` class) rather than letting it flash and be torn down a
+ * round-trip later.
+ *
+ * That gate is a guess about a token nothing has verified yet. Here is where it
+ * is settled:
+ *
+ * - `reveal(false)` — `whoami` confirmed the session. The markup is wrong for
+ * this user, so remove the nodes outright; the class can stay.
+ * - `reveal(true)` — there is no session after all (no token, or one `whoami`
+ * rejected). The markup is the correct thing to show, so drop the class.
+ *
+ * @param {boolean} reveal Whether the markup should end up visible.
+ */
+function reveal_anonymous_markup(reveal) {
+ // Stands the gate's own failsafe timer down: the guess has been ruled on.
+ window.__puter_session_settled = true;
+ if (reveal) {
+ document.documentElement.classList.remove('has-stored-session');
+ return;
+ }
+ document
+ .querySelectorAll('.hide-if-logged-in')
+ .forEach((el) => el.remove());
+}
+
/**
* Shows a Turnstile challenge modal for first-time temp user creation
* @param {Object} options - Configuration options
@@ -1778,6 +1812,10 @@ window.initgui = async function (options) {
* and without authenticating with the server.
*/
const bad_session_logout = async () => {
+ // The gate hid the anonymous markup on the strength of a stored
+ // token that has just turned out to be dead. Put it back, so the alert
+ // below isn't sitting on an empty page.
+ reveal_anonymous_markup(true);
try {
// TODO: i18n
await UIAlert({
@@ -1963,14 +2001,15 @@ window.initgui = async function (options) {
}
// update local user data
if (whoami) {
- // The server renders the /app/ landing overlay only for
- // requests it saw as anonymous, but its only signal is the session
- // cookie — which can be gone (e.g. browser restart) while the
- // localStorage session is still valid. whoami just proved this is
- // a logged-in user, so drop the overlay. This must happen before
- // the verification gates below: the overlay's max z-index would
- // cover them.
- document.getElementById('appLanding')?.remove();
+ // The shell renders its anonymous markup — the marketing
+ // homepage, an /app/ landing — off the session cookie alone,
+ // and that cookie can be gone (e.g. browser restart) while the
+ // localStorage session is still valid. The shell's gate has
+ // kept it from painting; whoami just proved this is a logged-in
+ // user, so drop it for good. This must happen before the
+ // verification gates below: the landing's max z-index would cover
+ // them.
+ reveal_anonymous_markup(false);
// Verification gates run in order: email → phone (SMS) → card,
// matching the server-side order in assertVerifiedAccount.
if (whoami.requires_email_confirmation) {
@@ -2056,6 +2095,11 @@ window.initgui = async function (options) {
// -------------------------------------------------------------------------------------
// Un-authed but not first visit -> try to log in/sign up
// -------------------------------------------------------------------------------------
+ // No session after all: the anonymous markup the shell sent is the correct
+ // thing to show, so undo its gate in case a stored token set it and
+ // then failed to authenticate.
+ if (!window.is_auth()) reveal_anonymous_markup(true);
+
// App landing pages (`/app/`, incl. `/desktop/app/`) require a
// real account even on a first visit — never a temp user. So does a share
// link: a share only ever reaches a real account, so a temporary one could