diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml index 2871a19c..45da8bc4 100644 --- a/.github/workflows/rat-dev-preview.yml +++ b/.github/workflows/rat-dev-preview.yml @@ -22,10 +22,13 @@ jobs: - name: Build app env: CINNY_BASE: /void/rat-dev/ + VITE_PREVIEW_DEMO: '1' NODE_OPTIONS: '--max_old_space_size=3072' run: npm run build - - name: Set background scene tier - run: node scripts/preview-config.js low + - name: Set background scene tier and the pretend homeserver + run: node scripts/preview-config.js low --demo + - name: Add the demo pose art + run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/ - name: Upload artifact # v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath # (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169). diff --git a/preview-demo/poses/eureka/amused.png b/preview-demo/poses/eureka/amused.png new file mode 100644 index 00000000..655c2271 Binary files /dev/null and b/preview-demo/poses/eureka/amused.png differ diff --git a/preview-demo/poses/eureka/annoyed.png b/preview-demo/poses/eureka/annoyed.png new file mode 100644 index 00000000..a142771e Binary files /dev/null and b/preview-demo/poses/eureka/annoyed.png differ diff --git a/preview-demo/poses/eureka/happy.png b/preview-demo/poses/eureka/happy.png new file mode 100644 index 00000000..dffdc754 Binary files /dev/null and b/preview-demo/poses/eureka/happy.png differ diff --git a/preview-demo/poses/eureka/neutral.png b/preview-demo/poses/eureka/neutral.png new file mode 100644 index 00000000..335cfb19 Binary files /dev/null and b/preview-demo/poses/eureka/neutral.png differ diff --git a/preview-demo/poses/eureka/surprised.png b/preview-demo/poses/eureka/surprised.png new file mode 100644 index 00000000..1d716bfd Binary files /dev/null and b/preview-demo/poses/eureka/surprised.png differ diff --git a/preview-demo/poses/eureka/thinking.png b/preview-demo/poses/eureka/thinking.png new file mode 100644 index 00000000..087e56e2 Binary files /dev/null and b/preview-demo/poses/eureka/thinking.png differ diff --git a/preview-demo/poses/nibu/amused.png b/preview-demo/poses/nibu/amused.png new file mode 100644 index 00000000..c13a3939 Binary files /dev/null and b/preview-demo/poses/nibu/amused.png differ diff --git a/preview-demo/poses/nibu/annoyed.png b/preview-demo/poses/nibu/annoyed.png new file mode 100644 index 00000000..f71ce0b7 Binary files /dev/null and b/preview-demo/poses/nibu/annoyed.png differ diff --git a/preview-demo/poses/nibu/happy.png b/preview-demo/poses/nibu/happy.png new file mode 100644 index 00000000..a0432c4e Binary files /dev/null and b/preview-demo/poses/nibu/happy.png differ diff --git a/preview-demo/poses/nibu/neutral.png b/preview-demo/poses/nibu/neutral.png new file mode 100644 index 00000000..86eb51c8 Binary files /dev/null and b/preview-demo/poses/nibu/neutral.png differ diff --git a/preview-demo/poses/nibu/resolute.png b/preview-demo/poses/nibu/resolute.png new file mode 100644 index 00000000..8ce6377f Binary files /dev/null and b/preview-demo/poses/nibu/resolute.png differ diff --git a/preview-demo/poses/nibu/surprised.png b/preview-demo/poses/nibu/surprised.png new file mode 100644 index 00000000..dc94588c Binary files /dev/null and b/preview-demo/poses/nibu/surprised.png differ diff --git a/preview-demo/poses/nibu/thinking.png b/preview-demo/poses/nibu/thinking.png new file mode 100644 index 00000000..e5d636ef Binary files /dev/null and b/preview-demo/poses/nibu/thinking.png differ diff --git a/preview-demo/poses/nibu/triumph.png b/preview-demo/poses/nibu/triumph.png new file mode 100644 index 00000000..d5a26ec2 Binary files /dev/null and b/preview-demo/poses/nibu/triumph.png differ diff --git a/preview-demo/poses/void/amused.png b/preview-demo/poses/void/amused.png new file mode 100644 index 00000000..c90c81c5 Binary files /dev/null and b/preview-demo/poses/void/amused.png differ diff --git a/preview-demo/poses/void/annoyed.png b/preview-demo/poses/void/annoyed.png new file mode 100644 index 00000000..fcff912f Binary files /dev/null and b/preview-demo/poses/void/annoyed.png differ diff --git a/preview-demo/poses/void/deadpan.png b/preview-demo/poses/void/deadpan.png new file mode 100644 index 00000000..6dc8083d Binary files /dev/null and b/preview-demo/poses/void/deadpan.png differ diff --git a/preview-demo/poses/void/glare.png b/preview-demo/poses/void/glare.png new file mode 100644 index 00000000..70860e32 Binary files /dev/null and b/preview-demo/poses/void/glare.png differ diff --git a/preview-demo/poses/void/happy.png b/preview-demo/poses/void/happy.png new file mode 100644 index 00000000..f20ec90a Binary files /dev/null and b/preview-demo/poses/void/happy.png differ diff --git a/preview-demo/poses/void/neutral.png b/preview-demo/poses/void/neutral.png new file mode 100644 index 00000000..aa2283a9 Binary files /dev/null and b/preview-demo/poses/void/neutral.png differ diff --git a/preview-demo/poses/void/point.png b/preview-demo/poses/void/point.png new file mode 100644 index 00000000..5bd2207a Binary files /dev/null and b/preview-demo/poses/void/point.png differ diff --git a/preview-demo/poses/void/reading.png b/preview-demo/poses/void/reading.png new file mode 100644 index 00000000..c2869474 Binary files /dev/null and b/preview-demo/poses/void/reading.png differ diff --git a/preview-demo/poses/void/surprised.png b/preview-demo/poses/void/surprised.png new file mode 100644 index 00000000..3dd340f8 Binary files /dev/null and b/preview-demo/poses/void/surprised.png differ diff --git a/preview-demo/poses/void/thinking.png b/preview-demo/poses/void/thinking.png new file mode 100644 index 00000000..895f3e6b Binary files /dev/null and b/preview-demo/poses/void/thinking.png differ diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 589f8efa..8b382878 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,12 +1,28 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier] +// in the repo stays 'off'. With --demo it also switches on the pretend homeserver (src/preview-demo): +// previewDemo true, and the pretend server as the only homeserver. Usage: +// node scripts/preview-config.js [tier] [--demo] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -const tier = process.argv[2] ?? 'low'; +const args = process.argv.slice(2); +const demo = args.includes('--demo'); +const tier = args.find((a) => !a.startsWith('--')) ?? 'low'; const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); const config = JSON.parse(fs.readFileSync(file, 'utf8')); config.backgroundScene = { ...config.backgroundScene, tier }; +if (demo) { + config.previewDemo = true; + config.defaultHomeserver = 0; + config.homeserverList = ['demo.invalid']; + config.allowCustomHomeservers = false; + config.featuredCommunities = { + openAsDefault: false, + spaces: ['#gamecult:demo.invalid'], + rooms: [], + servers: ['demo.invalid'], + }; +} fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); -console.log(`${file}: backgroundScene.tier = ${tier}`); +console.log(`${file}: backgroundScene.tier = ${tier}${demo ? ', previewDemo on' : ''}`); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7a0586eb..6c793e65 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -21,6 +21,9 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; backgroundScene?: BackgroundSceneConfig; + + // Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo). + previewDemo?: boolean; }; const ClientConfigContext = createContext(null); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 2f04a733..9d890bef 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -36,7 +36,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar export function Login() { const server = useAuthServer(); - const { hashRouter } = useClientConfig(); + const { hashRouter, previewDemo } = useClientConfig(); const { loginFlows } = useAuthFlows(); const [searchParams] = useSearchParams(); const loginSearchParams = useLoginSearchParams(searchParams); @@ -65,7 +65,7 @@ export function Login() { {parsedFlows.password && ( <> diff --git a/src/index.tsx b/src/index.tsx index 4aba7ed4..c2461fe8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ /* eslint-disable import/first */ +import './preview-demo/seed'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; @@ -52,6 +53,16 @@ if ('serviceWorker' in navigator) { }); } +// 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. +const installPreviewDemo = async () => { + if (import.meta.env.VITE_PREVIEW_DEMO !== '1') return; + const config = await fetch(`${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`) + .then((r) => r.json()) + .catch(() => ({})); + if (config.previewDemo === true) (await import('./preview-demo')).installDemo(); +}; + const mountApp = () => { const rootContainer = document.getElementById('root'); @@ -64,4 +75,4 @@ const mountApp = () => { root.render(); }; -mountApp(); +installPreviewDemo().finally(mountApp); diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts new file mode 100644 index 00000000..1ed52dc1 --- /dev/null +++ b/src/preview-demo/fixtures.ts @@ -0,0 +1,215 @@ +// Fixtures for the preview-only pretend homeserver. Public-safe by construction: the only +// names are GameCult's Minds and a fictional visitor; every room, line and id is invented here. +export const DEMO_SERVER = 'demo.invalid'; +export const DEMO_ORIGIN = `https://${DEMO_SERVER}`; +export const ME = `@demo:${DEMO_SERVER}`; +export const DEMO_DEVICE = 'DEMOPREVIEW'; +export const DEMO_TOKEN = 'demo-preview-token'; + +export type DemoEvent = { + type: string; + state_key?: string; + sender: string; + content: Record; + event_id: string; + origin_server_ts: number; + unsigned?: Record; +}; + +export type DemoUser = { id: string; name: string }; +export const USERS: DemoUser[] = [ + { id: ME, name: 'Demo' }, + { id: `@eureka:${DEMO_SERVER}`, name: 'Eureka' }, + { id: `@void:${DEMO_SERVER}`, name: 'Void' }, + { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, + { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, +]; +const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; + +export type DemoLine = { from: string; body: string; pose?: string; minutesAgo: number }; +export type DemoRoom = { + id: string; + alias?: string; + name: string; + space?: boolean; + direct?: boolean; + topic?: string; + members: string[]; + notice?: 'show' | 'study' | 'train'; + parent?: string; + steward?: { mind: string; repo: string }; + joined: boolean; + lines: DemoLine[]; +}; + +export const ROOT = `!root:${DEMO_SERVER}`; +export const PROJECTS = `!projects:${DEMO_SERVER}`; +export const GENERAL = `!general:${DEMO_SERVER}`; +export const COMMONS = `!commons:${DEMO_SERVER}`; + +const everyone = USERS.map((u) => u.id); +export const MIND_IDS = [user('eureka'), user('void'), user('nibu')]; +const minds = MIND_IDS; + +export const ROOMS: DemoRoom[] = [ + { + id: ROOT, + alias: `#gamecult:${DEMO_SERVER}`, + name: 'GameCult', + space: true, + members: [ME], + joined: true, + lines: [], + }, + { + id: GENERAL, + alias: `#general:${DEMO_SERVER}`, + name: 'General', + topic: 'A pretend room on a pretend server.', + members: everyone, + notice: 'train', + parent: ROOT, + joined: true, + lines: [ + { from: user('visitor'), body: 'Is anyone around? I just found this place.', minutesAgo: 42 }, + { + from: user('eureka'), + body: 'Welcome. This is a preview with no real server behind it.', + pose: 'happy', + minutesAgo: 40, + }, + { + from: user('void'), + body: 'The portrait beside a message is the pose its sender chose. Turn the setting on if you cannot see it.', + pose: 'reading', + minutesAgo: 38, + }, + { + from: user('nibu'), + body: 'Check the sky behind the login screen while you are here.', + pose: 'triumph', + minutesAgo: 35, + }, + { from: user('visitor'), body: 'Can I type here?', minutesAgo: 30 }, + { + from: user('eureka'), + body: 'Yes. Say something and a Mind will answer. The composer underlines misspelled words, so try "recieve".', + pose: 'amused', + minutesAgo: 29, + }, + { + from: user('void'), + body: 'Nothing you type leaves this page.', + pose: 'deadpan', + minutesAgo: 25, + }, + ], + }, + { + id: PROJECTS, + alias: `#projects:${DEMO_SERVER}`, + name: 'Projects', + space: true, + members: [ME], + parent: ROOT, + joined: true, + lines: [], + }, + { + id: `!ratatoskr:${DEMO_SERVER}`, + alias: `#ratatoskr:${DEMO_SERVER}`, + name: 'Ratatoskr', + topic: 'The chat client, built by the people using it.', + members: [ME, ...minds], + notice: 'study', + parent: PROJECTS, + steward: { mind: 'nibu', repo: 'Ratatoskr' }, + joined: true, + lines: [ + { + from: user('nibu'), + body: 'The ridge scene is on at the low tier in this preview.', + pose: 'resolute', + minutesAgo: 90, + }, + { + from: user('void'), + body: 'Backgrounded tabs now nudge sync when you come back, so the list is not stale.', + pose: 'point', + minutesAgo: 80, + }, + { + from: user('eureka'), + body: 'Messages from Minds carry a pose label; this one is thinking.', + pose: 'thinking', + minutesAgo: 70, + }, + { + from: user('nibu'), + body: 'This label is not in the pose catalogue, so the portrait falls back to neutral.', + pose: 'no-such-pose', + minutesAgo: 60, + }, + ], + }, + { + id: `!huginn:${DEMO_SERVER}`, + alias: `#huginn:${DEMO_SERVER}`, + name: 'Huginn', + topic: 'Memory for the Minds.', + members: [ME, user('eureka'), user('void')], + notice: 'show', + parent: PROJECTS, + steward: { mind: 'eureka', repo: 'Huginn' }, + joined: true, + lines: [ + { + from: user('eureka'), + body: 'This room shows no training line, because its notice says show.', + pose: 'neutral', + minutesAgo: 120, + }, + ], + }, + { + id: `!dm-eureka:${DEMO_SERVER}`, + name: 'Eureka', + direct: true, + members: [ME, user('eureka')], + joined: true, + lines: [ + { + from: user('eureka'), + body: 'A direct message. I answer here too.', + pose: 'happy', + minutesAgo: 15, + }, + { + from: user('eureka'), + body: 'Ask me anything; the answers are canned.', + pose: 'thinking', + minutesAgo: 14, + }, + ], + }, + { + id: COMMONS, + alias: `#commons:${DEMO_SERVER}`, + name: 'Commons', + topic: 'Open to anyone. Join to look inside.', + members: [user('visitor'), user('void')], + parent: ROOT, + joined: false, + lines: [ + { from: user('void'), body: 'You found the unjoined room.', pose: 'glare', minutesAgo: 200 }, + ], + }, +]; + +// A Mind's canned answer to whatever the visitor types: label and line. +export const REPLIES: { pose: string; body: string }[] = [ + { pose: 'thinking', body: 'Hm. Let me think about that.' }, + { pose: 'amused', body: 'That is a fair thing to say to a pretend server.' }, + { pose: 'happy', body: 'Noted. Nothing was sent anywhere.' }, + { pose: 'surprised', body: 'I did not expect that.' }, +]; diff --git a/src/preview-demo/index.ts b/src/preview-demo/index.ts new file mode 100644 index 00000000..f66dde43 --- /dev/null +++ b/src/preview-demo/index.ts @@ -0,0 +1 @@ +export { installDemo } from './install'; diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts new file mode 100644 index 00000000..36990a57 --- /dev/null +++ b/src/preview-demo/install.ts @@ -0,0 +1,146 @@ +/* eslint-disable no-param-reassign */ +// Patching the window's network entry points is this module's whole job. +// Routes the page's network through the pretend homeserver and refuses everything else. Installed +// 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 { DEMO_ORIGIN } from './fixtures'; +import { createDemoServer, DemoServer } from './server'; + +export type Route = 'own' | 'demo' | 'refuse'; + +export const routeOf = (url: string, pageOrigin: string): Route => { + let u: URL; + try { + u = new URL(url, pageOrigin); + } catch { + return 'refuse'; + } + if (u.protocol === 'data:' || u.protocol === 'blob:') return 'own'; + if (u.origin === DEMO_ORIGIN) return 'demo'; + const ws = u.protocol === 'ws:' || u.protocol === 'wss:'; + const sameHost = u.host === new URL(pageOrigin).host; + return sameHost && (u.origin === pageOrigin || ws) ? 'own' : 'refuse'; +}; + +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.'; + +const showBanner = (text: string) => { + const el = document.createElement('div'); + el.textContent = text; + el.setAttribute('role', 'status'); + el.style.cssText = + 'position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:96vw;' + + 'padding:0 8px;font:10px/1.5 system-ui,sans-serif;white-space:nowrap;opacity:0.9;color:#1b1b1b;background:#ffc46b;' + + 'border-radius:0 0 6px 6px;pointer-events:none;text-align:center'; + document.body.appendChild(el); +}; + +// The preview host's CSP sandbox has no allow-forms, so the browser cancels a native form +// submission before its submit event fires and no login button works. Deliver the event ourselves; +// nothing is submitted natively. Remove this when the host grants allow-forms to /void/rat-*. +const deliverSubmitEvents = (doc: Document) => { + const submit = (form: HTMLFormElement) => + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + doc.addEventListener( + 'click', + (e) => { + const button = (e.target as Element | null)?.closest?.( + 'button[type="submit"], input[type="submit"]' + ); + const form = (button as HTMLButtonElement | null)?.form; + if (!form || (button as HTMLButtonElement).disabled) return; + e.preventDefault(); + submit(form); + }, + true + ); + doc.addEventListener( + 'keydown', + (e) => { + const target = e.target as HTMLInputElement | null; + if (e.key !== 'Enter' || e.isComposing || target?.tagName !== 'INPUT' || !target.form) return; + e.preventDefault(); + submit(target.form); + }, + true + ); +}; + +const parseBody = (body: unknown): unknown => { + if (typeof body !== 'string') return undefined; + try { + return JSON.parse(body); + } catch { + return undefined; + } +}; + +export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +// Own-origin requests go to the real fetch, the demo origin to the pretend server, the rest throw. +export const guardedFetch = + (server: DemoServer, origin: string, realFetch: FetchFn): FetchFn => + async (input, init) => { + const req = input instanceof Request ? input : undefined; + const url = req ? req.url : String(input); + const route = routeOf(url, origin); + if (route === 'own') return realFetch(input, init); + if (route === 'refuse') throw new TypeError(refusal('fetch', url).message); + const method = init?.method ?? req?.method ?? 'GET'; + const raw = init?.body ?? (req ? await req.clone().text() : undefined); + const out = await server.handle({ + method, + url, + body: parseBody(raw), + signal: init?.signal ?? req?.signal ?? undefined, + }); + return new Response(JSON.stringify(out.body), { + status: out.status, + headers: { 'Content-Type': 'application/json' }, + }); + }; + +export const installDemo = (win: Window & typeof globalThis = window) => { + const server = createDemoServer(); + const { origin } = win.location; + const realFetch = win.fetch.bind(win); + + win.fetch = guardedFetch(server, origin, realFetch); + + const { WebSocket: RealSocket } = win; + class GuardedSocket extends RealSocket { + constructor(url: string | URL, protocols?: string | string[]) { + if (routeOf(String(url), origin) !== 'own') throw refusal('WebSocket', String(url)); + super(url, protocols); + } + } + win.WebSocket = GuardedSocket; + + const { open: realOpen } = win.XMLHttpRequest.prototype; + win.XMLHttpRequest.prototype.open = function open( + this: XMLHttpRequest, + method: string, + url: string | URL, + ...rest: unknown[] + ) { + if (routeOf(String(url), origin) !== 'own') throw refusal('XMLHttpRequest', String(url)); + return (realOpen as (...a: unknown[]) => void).call(this, method, url, ...rest); + } as typeof realOpen; + + const realBeacon = win.navigator.sendBeacon?.bind(win.navigator); + if (realBeacon) { + win.navigator.sendBeacon = (url: string | URL, data?: BodyInit | null) => + routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; + } + + deliverSubmitEvents(document); + const stored = win.localStorage.getItem('cinny_hs_base_url'); + showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); + return server; +}; diff --git a/src/preview-demo/seed.ts b/src/preview-demo/seed.ts new file mode 100644 index 00000000..29af3dcc --- /dev/null +++ b/src/preview-demo/seed.ts @@ -0,0 +1,10 @@ +// Imported first by src/index.tsx: the settings module reads localStorage when it is first +// imported, so a demo build's first-visit defaults must be stored before anything else loads. +// Pose portraits default off; the demo exists to show them. Does nothing without the build flag. +export const seedDemoSettings = (storage: Storage) => { + if (storage.getItem('settings') === null) { + storage.setItem('settings', JSON.stringify({ poseBubbles: true })); + } +}; + +if (import.meta.env.VITE_PREVIEW_DEMO === '1') seedDemoSettings(localStorage); diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts new file mode 100644 index 00000000..e3cc977e --- /dev/null +++ b/src/preview-demo/server.test.ts @@ -0,0 +1,270 @@ +/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */ +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 { createDemoServer, DemoServer } from './server'; +import { seedDemoSettings } from './seed'; +import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses'; +import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice'; + +const PAGE = 'https://page.test'; +const never = vi.fn(async () => new Response('real network')); + +const clientFor = (server: DemoServer): MatrixClient => + createClient({ + baseUrl: DEMO_ORIGIN, + fetchFn: guardedFetch(server, PAGE, never as never) as never, + }); + +const waitFor = (read: () => T | undefined, ms = 5000): Promise => + new Promise((resolve, reject) => { + const t0 = Date.now(); + const tick = () => { + const v = read(); + if (v !== undefined) resolve(v); + else if (Date.now() - t0 > ms) reject(new Error('timed out')); + else setTimeout(tick, 20); + }; + tick(); + }); + +const live: { server: DemoServer; mx: MatrixClient }[] = []; +afterEach(() => { + live.splice(0).forEach(({ server, mx }) => { + mx.stopClient(); + server.close(); + }); + never.mockClear(); +}); + +const syncedClient = async () => { + const server = createDemoServer(); + const mx = clientFor(server); + live.push({ server, mx }); + const login = await mx.loginWithPassword('demo', 'anything'); + mx.http.opts.accessToken = login.access_token; + await mx.startClient({ initialSyncLimit: 20 }); + await waitFor(() => (mx.getSyncState() === SyncState.Syncing ? true : undefined)); + return { server, mx, login }; +}; + +describe('routeOf', () => { + it('lets own-origin requests through, answers the demo origin, refuses the rest', () => { + expect(routeOf('/config.json', PAGE)).toBe('own'); + expect(routeOf(`${PAGE}/assets/a.js`, PAGE)).toBe('own'); + expect(routeOf('data:text/plain,hi', PAGE)).toBe('own'); + expect(routeOf(`${DEMO_ORIGIN}/_matrix/client/versions`, PAGE)).toBe('demo'); + expect(routeOf('https://matrix.org/_matrix/client/versions', PAGE)).toBe('refuse'); + expect(routeOf('https://page.test.evil.example/x', PAGE)).toBe('refuse'); + expect(routeOf('https://demo.invalid.evil.example/x', PAGE)).toBe('refuse'); + expect(routeOf('http://page.test/x', PAGE)).toBe('refuse'); + expect(routeOf('wss://matrix.org/socket', PAGE)).toBe('refuse'); + expect(routeOf('wss://page.test/socket', PAGE)).toBe('own'); + }); +}); + +describe('the pretend homeserver through matrix-js-sdk', () => { + it('logs in as demo with any password', async () => { + const server = createDemoServer(); + live.push({ server, mx: clientFor(server) }); + const res = await live[0].mx.loginWithPassword('demo', 'whatever'); + expect(res.user_id).toBe(ME); + expect(res.access_token).toBeTruthy(); + }); + + it('syncs the joined rooms, space tree, direct message and members', async () => { + const { mx } = await syncedClient(); + const joined = ROOMS.filter((r) => r.joined); + expect( + mx + .getRooms() + .map((r) => r.roomId) + .sort() + ).toEqual(joined.map((r) => r.id).sort()); + expect(mx.getRoom(GENERAL)?.name).toBe('General'); + expect(mx.getRoom(COMMONS)).toBeNull(); + const direct = mx.getAccountData('m.direct')?.getContent() as Record; + expect(Object.values(direct).flat()).toContain(ROOMS.find((r) => r.direct)?.id); + expect(mx.getRoom(GENERAL)?.getJoinedMemberCount()).toBeGreaterThan(3); + }); + + it('carries pose labels that the pose catalogue resolves, for known Minds only', async () => { + const { mx } = await syncedClient(); + const events = mx + .getRoom(GENERAL)! + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === 'm.room.message'); + const posed = events.filter((e) => e.getContent()[POSE_KEY]); + expect(posed.length).toBeGreaterThan(3); + posed.forEach((e) => { + const local = e.getSender()!.slice(1).split(':')[0]; + const mind = mindOfSender(local, undefined); + expect(mind, e.getSender()!).toBeDefined(); + expect(resolvePose(mind!, e.getContent()[POSE_KEY])).toBe(e.getContent()[POSE_KEY]); + }); + expect( + events.some((e) => mindOfSender(e.getSender()!.slice(1).split(':')[0]) === undefined) + ).toBe(true); + }); + + it('shows the training line only in the rooms whose notice says train', async () => { + const { mx } = await syncedClient(); + const lines = ROOMS.filter((r) => r.joined && !r.space && !r.direct).map((r) => { + const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', ''); + return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined]; + }); + expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false }); + }); + + it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => { + const { mx } = await syncedClient(); + const room = mx.getRoom(GENERAL)!; + const before = room.getLiveTimeline().getEvents().length; + const sent = await mx.sendTextMessage(GENERAL, 'hello pretend world'); + const mine = await waitFor(() => + room + .getLiveTimeline() + .getEvents() + .find((e) => e.getId() === sent.event_id && !e.status) + ); + expect(mine.getSender()).toBe(ME); + const reply = await waitFor( + () => + room + .getLiveTimeline() + .getEvents() + .find( + (e) => + e.getSender() !== ME && + e.getContent().body?.length && + room.getLiveTimeline().getEvents().indexOf(e) >= before + 1 + ), + 6000 + ); + expect(reply.getContent()[POSE_KEY]).toBeTruthy(); + const ours = room + .getLiveTimeline() + .getEvents() + .filter((e) => e.getContent().body === 'hello pretend world'); + expect(ours).toHaveLength(1); + }); + + it('joins the unjoined public room and then syncs it', async () => { + const { mx } = await syncedClient(); + const joinedRoom = new Promise((resolve) => + mx.on(ClientEvent.Room, (r) => resolve(r.roomId)) + ); + await mx.joinRoom(COMMONS); + expect(await joinedRoom).toBe(COMMONS); + }); + + it('resolves the root space alias and serves the hierarchy with steward metadata', async () => { + const { mx } = await syncedClient(); + const alias = await mx.getRoomIdForAlias('#gamecult:demo.invalid'); + expect(alias.room_id).toBeTruthy(); + const h = await mx.getRoomHierarchy(alias.room_id); + const stewards = h.rooms + .flatMap((r) => r.children_state) + .map((c) => (c.content as Record)['org.gamecult.steward']); + expect(stewards.filter(Boolean).length).toBe(2); + }); + + it('answers a long poll with nothing new after its timeout', async () => { + const server = createDemoServer(); + live.push({ server, mx: clientFor(server) }); + const first = await server.handle({ + method: 'GET', + url: `${DEMO_ORIGIN}/_matrix/client/v3/sync`, + }); + const token = (first.body as { next_batch: string }).next_batch; + const t0 = Date.now(); + const idle = await server.handle({ + method: 'GET', + url: `${DEMO_ORIGIN}/_matrix/client/v3/sync?since=${token}&timeout=150`, + }); + expect(Date.now() - t0).toBeGreaterThanOrEqual(140); + expect((idle.body as { rooms: { join: object } }).rooms.join).toEqual({}); + }); + + it('says so, rather than inventing an answer, for an endpoint it does not implement', async () => { + const server = createDemoServer(); + live.push({ server, mx: clientFor(server) }); + const res = await server.handle({ + method: 'GET', + url: `${DEMO_ORIGIN}/_matrix/client/v3/something/new`, + }); + expect(res.status).toBe(404); + expect((res.body as { errcode: string }).errcode).toBe('M_UNRECOGNIZED'); + }); +}); + +describe('the guard', () => { + it('refuses a real homeserver and never reaches the real fetch for it', async () => { + const server = createDemoServer(); + live.push({ server, mx: clientFor(server) }); + const f = guardedFetch(server, PAGE, never as never); + await expect(f('https://matrix.org/_matrix/client/versions')).rejects.toThrow( + /pretend server only/ + ); + await expect(f(new Request('https://converser.eu/_matrix/client/v3/login'))).rejects.toThrow( + /refused/ + ); + expect(never).not.toHaveBeenCalled(); + await f(`${PAGE}/config.json`); + expect(never).toHaveBeenCalledTimes(1); + const versions = await f(`${DEMO_ORIGIN}/_matrix/client/versions`); + expect((await versions.json()).versions.length).toBeGreaterThan(0); + }); + + it('installDemo refuses WebSocket, XMLHttpRequest and beacons to foreign origins', () => { + const store = new Map(); + const open = vi.fn(); + class Sock { + constructor(public url: string) {} + } + const win = { + location: { origin: PAGE }, + fetch: never, + WebSocket: Sock, + XMLHttpRequest: { prototype: { open } }, + navigator: { sendBeacon: vi.fn(() => true) }, + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + } as unknown as Window & typeof globalThis; + vi.stubGlobal('document', { + createElement: () => ({ style: {}, setAttribute: vi.fn() }), + body: { appendChild: vi.fn() }, + addEventListener: vi.fn(), + }); + const server = installDemo(win); + live.push({ server, mx: clientFor(server) }); + expect(() => new win.WebSocket('wss://matrix.org/x')).toThrow(/refused/); + expect(() => new win.WebSocket(`wss://page.test/x`)).not.toThrow(); + expect(() => win.XMLHttpRequest.prototype.open('GET', 'https://matrix.org/x')).toThrow( + /refused/ + ); + win.XMLHttpRequest.prototype.open('GET', '/same-origin'); + expect(open).toHaveBeenCalledTimes(1); + expect(win.navigator.sendBeacon('https://matrix.org/x')).toBe(false); + vi.unstubAllGlobals(); + }); + + it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => { + const fresh = new Map(); + seedDemoSettings({ + getItem: (k: string) => fresh.get(k) ?? null, + setItem: (k: string, v: string) => void fresh.set(k, v), + } as Storage); + expect(JSON.parse(fresh.get('settings')!)).toEqual({ poseBubbles: true }); + const chosen = new Map([['settings', '{"poseBubbles":false}']]); + seedDemoSettings({ + getItem: (k: string) => chosen.get(k) ?? null, + setItem: (k: string, v: string) => void chosen.set(k, v), + } as Storage); + expect(chosen.get('settings')).toBe('{"poseBubbles":false}'); + }); +}); diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts new file mode 100644 index 00000000..d56e34ee --- /dev/null +++ b/src/preview-demo/server.ts @@ -0,0 +1,421 @@ +// The preview-only pretend homeserver: a pure request handler over in-memory fixtures. It speaks +// just enough of the Matrix client-server API for matrix-js-sdk to log in, sync, show rooms, +// send messages and get a Mind's canned answer. It touches no network and no storage; the +// fetch guard that routes requests here lives in install.ts. +import { + COMMONS, + DEMO_DEVICE, + DEMO_ORIGIN, + DEMO_SERVER, + DEMO_TOKEN, + DemoEvent, + DemoRoom, + ME, + MIND_IDS, + REPLIES, + ROOMS, + USERS, +} from './fixtures'; + +export type DemoResponse = { status: number; body: unknown }; +export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal }; + +const POSE_KEY = 'org.gamecult.pose'; +const NOTICE_TYPE = 'org.gamecult.notice'; +const MAX_LONG_POLL_MS = 15000; + +const ok = (body: unknown = {}): DemoResponse => ({ status: 200, body }); +const err = (status: number, errcode: string, error: string): DemoResponse => ({ + status, + body: { errcode, error }, +}); + +type Stamped = { version: number; roomId: string; event: DemoEvent }; + +export type DemoServer = { + handle: (req: DemoRequest) => Promise; + /** Stops the pending Mind replies and long polls. */ + close: () => void; +}; + +export const createDemoServer = (now: () => number = Date.now): DemoServer => { + let version = 1; + let eventCounter = 0; + const joined = new Set(ROOMS.filter((r) => r.joined).map((r) => r.id)); + const timeline: Stamped[] = []; + const stateOf = new Map(); + const accountData = new Map(); + const waiters = new Set<() => void>(); + const timers = new Set>(); + let replyCounter = 0; + let accountDataVersion = 0; + let syncedJoined = new Set(joined); + let polled = false; + let closed = false; + + const mk = ( + type: string, + sender: string, + content: Record, + ts: number, + stateKey?: string + ): DemoEvent => { + eventCounter += 1; + return { + type, + sender, + content, + origin_server_ts: ts, + event_id: `$demo${eventCounter}`, + ...(stateKey === undefined ? {} : { state_key: stateKey }), + }; + }; + const nameOf = (id: string) => USERS.find((u) => u.id === id)?.name ?? id; + + const buildState = (room: DemoRoom): DemoEvent[] => { + const t0 = now() - 1000 * 60 * 60 * 24; + const creator = room.members[0] ?? ME; + const events: DemoEvent[] = [ + mk( + 'm.room.create', + creator, + room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' }, + t0, + '' + ), + mk('m.room.name', creator, { name: room.name }, t0, ''), + mk( + 'm.room.join_rules', + creator, + { join_rule: room.id === COMMONS ? 'public' : 'invite' }, + t0, + '' + ), + mk('m.room.history_visibility', creator, { history_visibility: 'shared' }, t0, ''), + mk( + 'm.room.power_levels', + creator, + { users: { [ME]: 100 }, users_default: 0, events_default: 0, state_default: 50 }, + t0, + '' + ), + ...room.members.map((m) => + mk('m.room.member', m, { membership: 'join', displayname: nameOf(m) }, t0, m) + ), + ]; + if (room.topic) events.push(mk('m.room.topic', creator, { topic: room.topic }, t0, '')); + if (room.alias) + events.push(mk('m.room.canonical_alias', creator, { alias: room.alias }, t0, '')); + if (room.notice) + events.push(mk(NOTICE_TYPE, creator, { version: 1, scope: room.notice }, t0, '')); + if (room.parent) { + events.push( + mk('m.space.parent', creator, { via: [DEMO_SERVER], canonical: true }, t0, room.parent) + ); + } + ROOMS.filter((c) => c.parent === room.id).forEach((c) => { + events.push( + mk( + 'm.space.child', + creator, + { + via: [DEMO_SERVER], + ...(c.steward + ? { + 'org.gamecult.steward': { ...c.steward, source: 'demo', steward: c.steward.mind }, + } + : {}), + }, + t0, + c.id + ) + ); + }); + return events; + }; + + ROOMS.forEach((room) => { + stateOf.set(room.id, buildState(room)); + room.lines.forEach((l) => { + timeline.push({ + version: 1, + roomId: room.id, + event: mk( + 'm.room.message', + l.from, + { msgtype: 'm.text', body: l.body, ...(l.pose ? { [POSE_KEY]: l.pose } : {}) }, + now() - l.minutesAgo * 60 * 1000 + ), + }); + }); + }); + timeline.sort((a, b) => a.event.origin_server_ts - b.event.origin_server_ts); + accountData.set('m.direct', { + [`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id), + }); + + const notify = () => { + waiters.forEach((w) => w()); + waiters.clear(); + }; + const push = (roomId: string, event: DemoEvent) => { + version += 1; + timeline.push({ version, roomId, event }); + notify(); + }; + const later = (ms: number, fn: () => void) => { + const t = setTimeout(() => { + timers.delete(t); + if (!closed) fn(); + }, ms); + timers.add(t); + }; + + // A Mind in the room answers whatever the visitor sent, with a pose from a short cycle. + const scheduleReply = (room: DemoRoom) => { + const mind = room.members.find((m) => MIND_IDS.includes(m)); + if (!mind) return; + const reply = REPLIES[replyCounter % REPLIES.length]; + replyCounter += 1; + later(1200, () => + push( + room.id, + mk( + 'm.room.message', + mind, + { msgtype: 'm.text', body: reply.body, [POSE_KEY]: reply.pose }, + now() + ) + ) + ); + }; + + const roomJson = (room: DemoRoom, since: number) => { + const full = since === 0; + const events = timeline + .filter((t) => t.roomId === room.id && (full || t.version > since)) + .map((t) => t.event); + return { + state: { events: full ? stateOf.get(room.id) : [] }, + timeline: { events, limited: false, prev_batch: 'p0' }, + ephemeral: { events: [] }, + account_data: { events: [] }, + unread_notifications: { highlight_count: 0, notification_count: 0 }, + summary: { + 'm.joined_member_count': room.members.length, + 'm.invited_member_count': 0, + 'm.heroes': [], + }, + }; + }; + + const syncBody = (since: number, joinedBefore: Set) => { + const join: Record = {}; + ROOMS.filter((r) => joined.has(r.id)).forEach((r) => { + const newlyJoined = !joinedBefore.has(r.id); + const j = roomJson(r, newlyJoined ? 0 : since); + if (since === 0 || newlyJoined || (j.timeline.events as unknown[]).length) join[r.id] = j; + }); + const ad: { type: string; content: unknown }[] = []; + if (since === 0 || accountDataVersion > since) { + accountData.forEach((content, type) => ad.push({ type, content })); + } + return { + next_batch: `d${version}`, + rooms: { join, invite: {}, leave: {} }, + account_data: { events: ad }, + presence: { events: [] }, + to_device: { events: [] }, + device_one_time_keys_count: { signed_curve25519: 50 }, + }; + }; + + const sync = async ( + since: number, + timeoutMs: number, + signal?: AbortSignal + ): Promise => { + if (since === 0) { + syncedJoined = new Set(joined); + return ok(syncBody(0, new Set())); + } + const changed = () => version > since || accountDataVersion > since; + if (!changed()) { + await new Promise((resolve) => { + const done = () => { + waiters.delete(done); + resolve(); + }; + waiters.add(done); + // The first poll answers quickly so the client leaves its Connecting state. + later(polled ? Math.min(timeoutMs, MAX_LONG_POLL_MS) : 200, done); + polled = true; + signal?.addEventListener('abort', done); + }); + } + const before = syncedJoined; + syncedJoined = new Set(joined); + return ok(syncBody(since, before)); + }; + + const roomOf = (id: string) => ROOMS.find((r) => r.id === id); + const hierarchy = () => { + const entry = (r: DemoRoom) => ({ + room_id: r.id, + name: r.name, + topic: r.topic, + canonical_alias: r.alias, + num_joined_members: r.members.length, + world_readable: false, + guest_can_join: false, + join_rule: r.id === COMMONS ? 'public' : 'invite', + ...(r.space ? { room_type: 'm.space' } : {}), + children_state: (stateOf.get(r.id) ?? []).filter((e) => e.type === 'm.space.child'), + }); + return ROOMS.filter((r) => !r.direct).map(entry); + }; + + const handle = async (req: DemoRequest): Promise => { + const u = new URL(req.url, DEMO_ORIGIN); + const p = decodeURIComponent(u.pathname); + const m = req.method.toUpperCase(); + const body = (req.body ?? {}) as Record; + let g: RegExpMatchArray | null; + + if (p === '/.well-known/matrix/client') + return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } }); + if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found'); + const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, ''); + if (p.endsWith('/versions')) + return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (c === '/login') { + if (m === 'GET') return ok({ flows: [{ type: 'm.login.password' }] }); + return ok({ + user_id: ME, + access_token: DEMO_TOKEN, + device_id: DEMO_DEVICE, + home_server: DEMO_SERVER, + }); + } + if (c === '/register') + return err(403, 'M_FORBIDDEN', 'Registration is closed on the pretend server.'); + if (c === '/account/whoami') return ok({ user_id: ME, device_id: DEMO_DEVICE }); + if (c === '/capabilities') return ok({ capabilities: {} }); + g = c.match(/^\/profile\/(.+)$/); + if (g) return ok({ displayname: nameOf(g[1]) }); + if (c.endsWith('/filter') && m === 'POST') return ok({ filter_id: '1' }); + if (c.includes('/filter/')) return ok({}); + if (c === '/sync') { + const raw = u.searchParams.get('since'); + const since = raw ? Number(raw.replace(/^d/, '')) || 0 : 0; + return sync(since, Number(u.searchParams.get('timeout') ?? 0), req.signal); + } + if (c === '/pushrules/' || c === '/pushrules') { + return ok({ global: { override: [], underride: [], room: [], sender: [], content: [] } }); + } + g = c.match(/^\/user\/[^/]+\/account_data\/(.+)$/); + if (g) { + if (m === 'PUT') { + accountData.set(g[1], req.body ?? {}); + version += 1; + accountDataVersion = version; + notify(); + return ok({}); + } + return accountData.has(g[1]) + ? ok(accountData.get(g[1])) + : err(404, 'M_NOT_FOUND', 'No account data'); + } + g = c.match(/^\/directory\/room\/(.+)$/); + if (g) { + const room = ROOMS.find((r) => r.alias === g![1]); + return room + ? ok({ room_id: room.id, servers: [DEMO_SERVER] }) + : err(404, 'M_NOT_FOUND', 'Room alias not found'); + } + if (c === '/joined_rooms') return ok({ joined_rooms: [...joined] }); + g = c.match(/^\/rooms\/([^/]+)\/hierarchy$/); + if (g) return ok({ rooms: hierarchy() }); + g = c.match(/^\/(?:rooms\/([^/]+)\/join|join\/([^/]+))$/); + if (g && m === 'POST') { + const target = g[1] ?? g[2]; + const room = roomOf(target) ?? ROOMS.find((r) => r.alias === target); + if (!room) return err(404, 'M_NOT_FOUND', 'No such room'); + if (!joined.has(room.id)) { + joined.add(room.id); + if (!room.members.includes(ME)) room.members.push(ME); + stateOf.set(room.id, buildState(room)); + version += 1; + notify(); + } + return ok({ room_id: room.id }); + } + g = c.match( + /^\/rooms\/([^/]+)\/(?:leave|forget|read_markers|typing\/[^/]+|receipt\/.+|report\/.+)$/ + ); + if (g) return ok({}); + g = c.match(/^\/rooms\/([^/]+)\/send\/([^/]+)\/([^/]+)$/); + if (g && m === 'PUT') { + const room = roomOf(g[1]); + if (!room || !joined.has(room.id)) return err(403, 'M_FORBIDDEN', 'Not in room'); + const event = mk(g[2], ME, body, now()); + event.unsigned = { transaction_id: g[3] }; + push(room.id, event); + if (g[2] === 'm.room.message') scheduleReply(room); + return ok({ event_id: event.event_id }); + } + g = c.match(/^\/rooms\/([^/]+)\/state\/([^/]+)(?:\/(.*))?$/); + if (g) { + const room = roomOf(g[1]); + if (!room) return err(404, 'M_NOT_FOUND', 'No such room'); + const key = g[3] ?? ''; + if (m === 'PUT') { + const event = mk(g[2], ME, body, now(), key); + stateOf.set(room.id, [ + ...(stateOf.get(room.id) ?? []).filter((e) => !(e.type === g![2] && e.state_key === key)), + event, + ]); + push(room.id, event); + return ok({ event_id: event.event_id }); + } + const found = (stateOf.get(room.id) ?? []).find( + (e) => e.type === g![2] && e.state_key === key + ); + return found ? ok(found.content) : err(404, 'M_NOT_FOUND', 'No such state event'); + } + g = c.match(/^\/rooms\/([^/]+)\/state$/); + if (g) return ok(stateOf.get(g[1]) ?? []); + g = c.match(/^\/rooms\/([^/]+)\/members$/); + if (g) { + return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') }); + } + g = c.match(/^\/rooms\/([^/]+)\/messages$/); + if (g) return ok({ chunk: [], start: 'p0', end: 'p0' }); + g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/); + if (g) return err(404, 'M_NOT_FOUND', 'Not available on the pretend server'); + if (c === '/keys/upload') return ok({ one_time_key_counts: { signed_curve25519: 50 } }); + if (c === '/keys/query') return ok({ device_keys: {}, failures: {} }); + if (c === '/keys/claim') return ok({ one_time_keys: {}, failures: {} }); + if (c === '/keys/changes') return ok({ changed: [], left: [] }); + if (c.startsWith('/keys/')) return ok({}); + if (c.startsWith('/sendToDevice/')) return ok({}); + if (c.startsWith('/room_keys/')) return err(404, 'M_NOT_FOUND', 'No backup'); + if (c === '/devices') + return ok({ devices: [{ device_id: DEMO_DEVICE, display_name: 'Preview' }] }); + if (c === '/thirdparty/protocols' || c === '/voip/turnServer') return ok({}); + if (c.startsWith('/presence/')) return ok({}); + if (c === '/user_directory/search') return ok({ results: [], limited: false }); + if (p.endsWith('/media/config') || p.endsWith('/config')) return ok({ 'm.upload.size': 0 }); + return err(404, 'M_UNRECOGNIZED', `The pretend server does not implement ${m} ${p}`); + }; + + return { + handle, + close: () => { + closed = true; + timers.forEach(clearTimeout); + timers.clear(); + notify(); + }, + }; +};