From 5bb78b21cf0d27c9f5b7498f28aa9138428bfdbe Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:22:43 +0000 Subject: [PATCH] preview demo: drop a stored login for a real homeserver before the app reads it Every preview shares the origin minds.gamecult.org and so one localStorage. A login another preview left (cinny_hs_base_url of a real homeserver) made the demo connect to that server, which the guard refuses, so SpecVersions showed 'Unable to connect to the homeserver'. installDemo now drops any stored session whose base is not the demo origin, and the service worker is registered after that so it is never handed the dropped session. Replaces the foreign-session banner text. Co-Authored-By: Claude Sonnet 5.5 --- src/app/state/sessions.ts | 20 ++++++------ src/index.tsx | 46 ++++++++++++++------------ src/preview-demo/install.ts | 15 ++++++--- src/preview-demo/server.test.ts | 57 ++++++++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index a9c34ce9..19f2212d 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -40,17 +40,17 @@ export function setFallbackSession( localStorage.setItem('cinny_user_id', userId); localStorage.setItem('cinny_hs_base_url', baseUrl); } -export const removeFallbackSession = () => { - localStorage.removeItem('cinny_hs_base_url'); - localStorage.removeItem('cinny_user_id'); - localStorage.removeItem('cinny_device_id'); - localStorage.removeItem('cinny_access_token'); +export const removeFallbackSession = (storage: Storage = localStorage) => { + storage.removeItem('cinny_hs_base_url'); + storage.removeItem('cinny_user_id'); + storage.removeItem('cinny_device_id'); + storage.removeItem('cinny_access_token'); }; -export const getFallbackSession = (): Session | undefined => { - const baseUrl = localStorage.getItem('cinny_hs_base_url'); - const userId = localStorage.getItem('cinny_user_id'); - const deviceId = localStorage.getItem('cinny_device_id'); - const accessToken = localStorage.getItem('cinny_access_token'); +export const getFallbackSession = (storage: Storage = localStorage): Session | undefined => { + const baseUrl = storage.getItem('cinny_hs_base_url'); + const userId = storage.getItem('cinny_user_id'); + const deviceId = storage.getItem('cinny_device_id'); + const accessToken = storage.getItem('cinny_access_token'); if (baseUrl && userId && deviceId && accessToken) { const session: Session = { diff --git a/src/index.tsx b/src/index.tsx index 6c7a3bc2..51e9760f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,29 +30,32 @@ import { getFallbackSession } from './app/state/sessions'; document.body.classList.add(configClass, varsClass); -// Register Service Worker -if ('serviceWorker' in navigator) { - const swUrl = - import.meta.env.MODE === 'production' - ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` - : `/dev-sw.js?dev-sw`; +// Register Service Worker. After the preview demo has settled the stored session: the worker is +// handed that session, and the demo may drop one it cannot serve. +const registerServiceWorker = () => { + if ('serviceWorker' in navigator) { + const swUrl = + import.meta.env.MODE === 'production' + ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` + : `/dev-sw.js?dev-sw`; - const sendSessionToSW = () => { - const session = getFallbackSession(); - pushSessionToSW(session?.baseUrl, session?.accessToken); - }; + const sendSessionToSW = () => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken); + }; - navigator.serviceWorker.register(swUrl).then(sendSessionToSW); - navigator.serviceWorker.ready.then(sendSessionToSW); + navigator.serviceWorker.register(swUrl).then(sendSessionToSW); + navigator.serviceWorker.ready.then(sendSessionToSW); - navigator.serviceWorker.addEventListener('message', (ev) => { - const { type } = ev.data ?? {}; + navigator.serviceWorker.addEventListener('message', (ev) => { + const { type } = ev.data ?? {}; - if (type === 'requestSession') { - sendSessionToSW(); - } - }); -} + if (type === 'requestSession') { + sendSessionToSW(); + } + }); + } +}; // The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev // workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it. @@ -76,4 +79,7 @@ const mountApp = () => { root.render(); }; -installPreviewDemo().finally(mountApp); +installPreviewDemo().finally(() => { + registerServiceWorker(); + mountApp(); +}); diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index a7b66de9..10b9a3ee 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -4,6 +4,7 @@ // only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx). // Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any // other origin is refused, so no real homeserver can be contacted while this is on. +import { removeFallbackSession } from '../app/state/sessions'; import { DEMO_ORIGIN } from './fixtures'; import { createDemoServer, DemoServer } from './server'; @@ -27,8 +28,6 @@ const refusal = (what: string, url: string) => new Error(`preview: pretend server only; ${what} to ${url} refused`); const BANNER_TEXT = 'preview: pretend server. Any password works.'; -const FOREIGN_SESSION_TEXT = - 'preview: pretend server. A login for another server is stored here and cannot be used.'; // In flow, above the app: body becomes a column and the app root takes the rest, so the banner // never covers the room header or the connection strip. @@ -78,7 +77,16 @@ export const guardedFetch = }); }; +// Every preview of this site shares one origin, so one localStorage: a login another preview left +// (a real homeserver) would make the app connect to that server, which the guard refuses. The +// demo has one server, so a stored login for any other is dropped before the app reads it. +export const dropForeignSession = (storage: Storage) => { + const base = storage.getItem('cinny_hs_base_url'); + if (base !== null && base !== DEMO_ORIGIN) removeFallbackSession(storage); +}; + export const installDemo = (win: Window & typeof globalThis = window) => { + dropForeignSession(win.localStorage); const server = createDemoServer(); const { origin } = win.location; const realFetch = win.fetch.bind(win); @@ -111,7 +119,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => { routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; } - const stored = win.localStorage.getItem('cinny_hs_base_url'); - showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); + showBanner(BANNER_TEXT); return server; }; diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index d277b97b..ec9031c1 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -2,9 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk'; import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures'; -import { guardedFetch, installDemo, routeOf } from './install'; +import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install'; import { createDemoServer, DemoServer } from './server'; import { seedDemoSettings } from './seed'; +import { getFallbackSession } from '../app/state/sessions'; import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses'; import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice'; @@ -233,6 +234,7 @@ describe('the guard', () => { localStorage: { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), }, } as unknown as Window & typeof globalThis; vi.stubGlobal('document', { @@ -254,6 +256,59 @@ describe('the guard', () => { vi.unstubAllGlobals(); }); + it('drops a stored login for a real homeserver before the app reads it, keeps the demo one', () => { + const SESSION = { + cinny_access_token: 'tok', + cinny_device_id: 'DEV', + cinny_user_id: '@a:b', + cinny_hs_base_url: 'https://chat.gamecult.org', + }; + const store = new Map(Object.entries({ ...SESSION, settings: '{}' })); + const storage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + } as Storage; + dropForeignSession(storage); + expect(getFallbackSession(storage)).toBeUndefined(); + expect([...store.keys()]).toEqual(['settings']); + + Object.entries({ ...SESSION, cinny_hs_base_url: DEMO_ORIGIN }).forEach(([k, v]) => + store.set(k, v) + ); + dropForeignSession(storage); + expect(getFallbackSession(storage)?.baseUrl).toBe(DEMO_ORIGIN); + }); + + it('installDemo clears a foreign login so the app starts at the login screen', () => { + const store = new Map([ + ['cinny_hs_base_url', 'https://chat.gamecult.org'], + ['cinny_access_token', 'tok'], + ['cinny_device_id', 'DEV'], + ['cinny_user_id', '@a:b'], + ]); + const win = { + location: { origin: PAGE }, + fetch: never, + WebSocket: class {}, + XMLHttpRequest: { prototype: { open: vi.fn() } }, + navigator: {}, + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + removeItem: (k: string) => void store.delete(k), + }, + } as unknown as Window & typeof globalThis; + vi.stubGlobal('document', { + createElement: () => ({ style: {}, setAttribute: vi.fn() }), + getElementById: () => null, + body: { style: {}, insertBefore: vi.fn() }, + }); + const server = installDemo(win); + live.push({ server, mx: clientFor(server) }); + expect(store.size).toBe(0); + vi.unstubAllGlobals(); + }); + it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => { const fresh = new Map(); seedDemoSettings({