-
- )
+ return
}
diff --git a/app/layout/BottomNav.test.tsx b/app/layout/BottomNav.test.tsx
new file mode 100644
index 000000000..c93d33d07
--- /dev/null
+++ b/app/layout/BottomNav.test.tsx
@@ -0,0 +1,47 @@
+import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test'
+import { cleanup, render, within } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { setupDom, teardownDom } from '../test-setup'
+import { BottomNav } from './BottomNav'
+
+// `@testing-library/dom`'s `screen` singleton snapshots `document` the
+// moment the module is first imported (dist/screen.js), so it only works
+// when a global document exists before any test file's imports run — which
+// means registering it process-wide via bunfig's preload, for every
+// workspace in this monorepo. That broke unrelated suites elsewhere (see
+// test-setup.ts). Using the queries `render` returns needs the DOM only once
+// the test body actually runs, so registering it here in beforeAll, scoped to
+// this file, is enough.
+beforeAll(setupDom)
+afterAll(teardownDom)
+
+// Every render is appended to the same document and stays there, so without
+// this each test sees the leftovers of the ones before it.
+afterEach(cleanup)
+
+// Queries are scoped to the container this render owns rather than the whole
+// body. The hub renders a link labelled Filters too, so a document-wide query
+// finds more than one and getByRole throws for being ambiguous. Whether that
+// happened depended on which files had already run, which is why this passed
+// locally and failed in CI.
+test('shows the four primary destinations in order', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const labels = within(container)
+ .getAllByRole('link')
+ .map((link) => link.textContent)
+ expect(labels).toEqual(['Map', 'Filters', 'Alerts', 'Me'])
+})
+
+test('marks the active destination for assistive tech', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const active = within(container).getByRole('link', { name: 'Filters' })
+ expect(active.getAttribute('aria-current')).toBe('page')
+})
diff --git a/app/layout/BottomNav.tsx b/app/layout/BottomNav.tsx
new file mode 100644
index 000000000..f7ab6395c
--- /dev/null
+++ b/app/layout/BottomNav.tsx
@@ -0,0 +1,26 @@
+import { NavLink } from 'react-router'
+
+const DESTINATIONS = [
+ { to: '/map', label: 'Map' },
+ { to: '/filters', label: 'Filters' },
+ { to: '/alerts', label: 'Alerts' },
+ { to: '/profile', label: 'Me' },
+] as const
+
+export function BottomNav() {
+ return (
+
+ )
+}
diff --git a/app/layout/Shell.tsx b/app/layout/Shell.tsx
new file mode 100644
index 000000000..ff0b904ef
--- /dev/null
+++ b/app/layout/Shell.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from 'react-router'
+import { BottomNav } from './BottomNav'
+
+export function Shell() {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/app/pages/AlertsPage.tsx b/app/pages/AlertsPage.tsx
new file mode 100644
index 000000000..02fdf9a78
--- /dev/null
+++ b/app/pages/AlertsPage.tsx
@@ -0,0 +1,8 @@
+export function AlertsPage() {
+ return (
+
+
+
+ )
+}
diff --git a/app/pages/Hub.test.tsx b/app/pages/Hub.test.tsx
new file mode 100644
index 000000000..36b45e127
--- /dev/null
+++ b/app/pages/Hub.test.tsx
@@ -0,0 +1,26 @@
+import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test'
+import { cleanup, render, within } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { setupDom, teardownDom } from '../test-setup'
+import { Hub } from './Hub'
+
+beforeAll(setupDom)
+afterAll(teardownDom)
+
+// Every render is appended to the same document and stays there, so without
+// this each test sees the leftovers of the ones before it. The bottom nav
+// renders a link labelled Filters as well, so a stale render from either file
+// can make the other's query ambiguous.
+afterEach(cleanup)
+
+test('links to the four primary surfaces without a session', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const hrefs = within(container)
+ .getAllByRole('link')
+ .map((link) => link.getAttribute('href'))
+ expect(hrefs).toEqual(['/map', '/filters', '/alerts', '/profile'])
+})
diff --git a/app/pages/Hub.tsx b/app/pages/Hub.tsx
new file mode 100644
index 000000000..90b6ee890
--- /dev/null
+++ b/app/pages/Hub.tsx
@@ -0,0 +1,27 @@
+import { Link } from 'react-router'
+
+const DESTINATIONS = [
+ { to: '/map', label: 'Map' },
+ { to: '/filters', label: 'Filters' },
+ { to: '/alerts', label: 'Alerts' },
+ { to: '/profile', label: 'Profile' },
+] as const
+
+export function Hub() {
+ return (
+
+
+ Account reset and linked accounts arrive in a later plan.
+
+
+ )
+}
diff --git a/app/routes.test.tsx b/app/routes.test.tsx
new file mode 100644
index 000000000..cad833d6a
--- /dev/null
+++ b/app/routes.test.tsx
@@ -0,0 +1,24 @@
+import { expect, test } from 'bun:test'
+import { ROUTES } from './routes'
+
+test('every spec route is present exactly once', () => {
+ const paths = ROUTES.map((route) => route.path).sort()
+ expect(paths).toEqual(
+ [
+ '*',
+ '/',
+ '/alerts',
+ '/filters',
+ '/locales',
+ '/map',
+ '/playground',
+ '/profile',
+ ].sort(),
+ )
+})
+
+test('every route element is lazy so it becomes its own chunk', () => {
+ for (const route of ROUTES) {
+ expect(typeof route.lazy).toBe('function')
+ }
+})
diff --git a/app/routes.tsx b/app/routes.tsx
new file mode 100644
index 000000000..ea495850b
--- /dev/null
+++ b/app/routes.tsx
@@ -0,0 +1,68 @@
+import type { RouteObject } from 'react-router'
+import { Shell } from './layout/Shell'
+
+/*
+ * Every route is lazy so the bundler gives each one its own chunk. The map
+ * route is the only one that will ever pull in MapLibre and deck.gl, and that
+ * only holds if nothing here imports a page eagerly.
+ */
+export const ROUTES: RouteObject[] = [
+ {
+ path: '/',
+ lazy: async () => ({ Component: (await import('./pages/Hub')).Hub }),
+ },
+ {
+ path: '/map',
+ lazy: async () => ({
+ Component: (await import('./pages/MapPage')).MapPage,
+ }),
+ },
+ {
+ path: '/filters',
+ lazy: async () => ({
+ Component: (await import('./pages/FiltersPage')).FiltersPage,
+ }),
+ },
+ {
+ path: '/alerts',
+ lazy: async () => ({
+ Component: (await import('./pages/AlertsPage')).AlertsPage,
+ }),
+ },
+ {
+ path: '/profile',
+ lazy: async () => ({
+ Component: (await import('./pages/Profile')).Profile,
+ }),
+ },
+ {
+ path: '/locales',
+ lazy: async () => ({
+ Component: (await import('./pages/Locales')).Locales,
+ }),
+ },
+ {
+ path: '/playground',
+ lazy: async () => ({
+ Component: (await import('./pages/Playground')).Playground,
+ }),
+ },
+ {
+ path: '*',
+ lazy: async () => ({
+ Component: (await import('./pages/NotFound')).NotFound,
+ }),
+ },
+]
+
+/*
+ * The route table `createBrowserRouter` actually consumes: ROUTES nested as
+ * children of one layout route rendering Shell, which owns the bottom nav.
+ * ROUTES itself stays flat because Task 1's test asserts against that shape.
+ */
+export const ROUTER_ROUTES: RouteObject[] = [
+ {
+ Component: Shell,
+ children: ROUTES,
+ },
+]
diff --git a/app/session/types.ts b/app/session/types.ts
new file mode 100644
index 000000000..944c56d18
--- /dev/null
+++ b/app/session/types.ts
@@ -0,0 +1,9 @@
+export interface SessionUser {
+ loggedIn: boolean
+ username?: string
+ perms: Record
+}
+
+export interface SessionSettings {
+ user: SessionUser
+}
diff --git a/app/session/useSession.test.ts b/app/session/useSession.test.ts
new file mode 100644
index 000000000..024634751
--- /dev/null
+++ b/app/session/useSession.test.ts
@@ -0,0 +1,26 @@
+import { afterEach, expect, mock, test } from 'bun:test'
+import { fetchSession } from './useSession'
+
+const originalFetch = globalThis.fetch
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+test('returns the parsed payload on success', async () => {
+ globalThis.fetch = mock(
+ async () =>
+ new Response(JSON.stringify({ user: { loggedIn: true, perms: {} } }), {
+ status: 200,
+ }),
+ ) as unknown as typeof fetch
+ const settings = await fetchSession()
+ expect(settings.user.loggedIn).toBe(true)
+})
+
+test('throws with the status when the request fails', async () => {
+ globalThis.fetch = mock(
+ async () => new Response('nope', { status: 500 }),
+ ) as unknown as typeof fetch
+ expect(fetchSession()).rejects.toThrow('500')
+})
diff --git a/app/session/useSession.ts b/app/session/useSession.ts
new file mode 100644
index 000000000..dd2b74761
--- /dev/null
+++ b/app/session/useSession.ts
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react'
+import type { SessionSettings } from './types'
+
+export async function fetchSession(): Promise {
+ const response = await fetch('/api/settings', { credentials: 'same-origin' })
+ if (!response.ok) {
+ throw new Error(`GET /api/settings failed: ${response.status}`)
+ }
+ return (await response.json()) as SessionSettings
+}
+
+export interface UseSessionResult {
+ status: 'loading' | 'success' | 'error'
+ data: SessionSettings | undefined
+ error: Error | undefined
+}
+
+export function useSession(): UseSessionResult {
+ const [status, setStatus] = useState('loading')
+ const [data, setData] = useState(undefined)
+ const [error, setError] = useState(undefined)
+
+ useEffect(() => {
+ let cancelled = false
+ fetchSession()
+ .then((settings) => {
+ if (cancelled) return
+ setData(settings)
+ setStatus('success')
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return
+ setError(err instanceof Error ? err : new Error(String(err)))
+ setStatus('error')
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ return { status, data, error }
+}
diff --git a/app/test-setup.ts b/app/test-setup.ts
new file mode 100644
index 000000000..3ab112071
--- /dev/null
+++ b/app/test-setup.ts
@@ -0,0 +1,27 @@
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+
+/*
+ * bunfig.toml's [test] table has no per-directory scoping: a plain preload
+ * registers happy-dom globally for the whole `bun test` run, which covers
+ * every workspace in this monorepo, not just app/. That broke three
+ * unrelated suites when tried: packages/masterfile (happy-dom's fetch
+ * enforces CORS on a cross-origin request the real test relies on),
+ * server/test/fetchJson (Bun.serve's `Response.json` resolved to happy-dom's
+ * Response class instead of the native one, corrupting the wire format),
+ * and app/build.test.ts (Vite's bundled code branches on `typeof document`,
+ * and a permanently-present `document` global sent it down a browser code
+ * path that doesn't apply here).
+ *
+ * `beforeAll`/`afterAll` declared inside a test file are scoped to that
+ * file, unlike ones declared in a preload script, which run once for the
+ * whole process. Call `setupDom`/`teardownDom` from a file's own
+ * `beforeAll`/`afterAll` to register happy-dom only around that file's
+ * tests, instead of registering it here at preload time.
+ */
+export function setupDom() {
+ GlobalRegistrator.register()
+}
+
+export async function teardownDom() {
+ await GlobalRegistrator.unregister()
+}
diff --git a/bun.lock b/bun.lock
index 2f1e82de3..87b23a29f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -88,12 +88,15 @@
"@biomejs/biome": "2.5.10",
"@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.2.2",
+ "@happy-dom/global-registrator": "20.11.6",
"@rm/types": "*",
"@rm/vite-plugins": "*",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@sentry/vite-plugin": "4.6.2",
"@tailwindcss/vite": "4.3.3",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
"@types/dlv": "^1.1.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.20.1",
@@ -103,6 +106,7 @@
"bun-types": "1.4.0",
"commitizen": "^4.3.0",
"cz-conventional-commit": "^1.0.6",
+ "happy-dom": "20.11.6",
"husky": "^8.0.1",
"monaco-editor": "^0.41.0",
"nodemon": "3.1.14",
@@ -446,6 +450,8 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.6" } }, "sha512-ZQ47qUTeNbGhHkCGExJ1oZhruoxKRaxO44RgFETl3T4c1rRxIBlAOnM3SAH4XHu7Ue2owJXP+jOx1vOuKuxcSg=="],
+
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
@@ -756,6 +762,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
"@turf/bbox": ["@turf/bbox@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw=="],
"@turf/boolean-contains": ["@turf/boolean-contains@7.3.5", "", { "dependencies": { "@turf/bbox": "7.3.5", "@turf/boolean-point-in-polygon": "7.3.5", "@turf/boolean-point-on-line": "7.3.5", "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@turf/line-split": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig=="],
@@ -792,6 +802,8 @@
"@turf/truncate": ["@turf/truncate@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ=="],
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
"@types/config": ["@types/config@3.3.5", "", {}, "sha512-itq2HtXQBrNUKwMNZnb9mBRE3T99VYCdl1gjST9rq+9kFaB1iMMGuDeZnP88qid73DnpAMKH9ZolqDpS1Lz7+w=="],
"@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="],
@@ -824,6 +836,8 @@
"@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
@@ -872,6 +886,8 @@
"argv-formatter": ["argv-formatter@1.0.0", "", {}, "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw=="],
+ "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="],
@@ -922,6 +938,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"byte-counter": ["byte-counter@0.1.0", "", {}, "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ=="],
@@ -1078,6 +1096,8 @@
"deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-file": ["detect-file@1.0.0", "", {}, "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q=="],
@@ -1096,6 +1116,8 @@
"doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
+ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
@@ -1120,6 +1142,8 @@
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
"env-ci": ["env-ci@10.0.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -1294,6 +1318,8 @@
"handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="],
+ "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="],
+
"has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="],
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -1560,6 +1586,8 @@
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="],
"magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
@@ -1778,6 +1806,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
@@ -2164,7 +2194,7 @@
"webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="],
- "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -2210,6 +2240,8 @@
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+ "@apollo/server/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
"@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -2816,6 +2848,10 @@
"pogo-data-generator/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
+ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
+ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"protobufjs/long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
diff --git a/docs/superpowers/plans/2026-08-24-shell-and-ia.md b/docs/superpowers/plans/2026-08-24-shell-and-ia.md
new file mode 100644
index 000000000..96a68a404
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-shell-and-ia.md
@@ -0,0 +1,593 @@
+# Shell and IA Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the 2.0 client a navigable shell: every route from the spec's IA, a mobile bottom nav, a hub at `/`, a working `/profile`, and a server-side per-user flag that decides which shell a request gets.
+
+**Architecture:** `app/` gains a `react-router` tree where every route is its own lazy chunk. The shell bootstraps from `GET /api/settings`, the same endpoint 1.0 already uses, so nothing here depends on the WebSocket transport that session 3 designs. On the server, `clientRouter` learns a second route table and serves `app.html` to users whose row carries the 2.0 flag.
+
+**Tech Stack:** React 19, react-router 8, Tailwind v4, TypeScript strict, `bun test` with Testing Library and happy-dom.
+
+## Assumptions
+
+Four calls made while writing this plan. Each is reversible, and each is called out because a reader could reasonably have expected the other choice.
+
+1. **Router is `react-router` 8, not TanStack Router.** It is already a dependency at `^8.3.0` for 1.0, so this adds nothing to the tree. TanStack's typed params would suit a strict codebase, but the spec never asked for typed routes and there are only ten of them. Swapping later touches route definitions, not business logic.
+2. **Component tests run on `bun test`, not Vitest.** The spec's §7 says "Vitest + Testing Library." That was written before the foundation plan moved everything to `bun:test`. Bun's runner is API-compatible for `describe`/`test`/`expect` and drives the DOM through happy-dom, so this honours the intent (Testing Library for components) without carrying two runners. If a Vitest-only feature is ever genuinely needed, revisit.
+3. **The spec's "Deleted by this IA" list is NOT in scope here.** `/data-management`, Backups, the tutorial, the Poracle modal and `HookSelection.jsx` all live in `src/`, which is the shell most users are still served. Deleting them now degrades the running app for everyone who has not been flagged over. They go when 1.0 retires. The Backups removal additionally drops a database table and needs its own explicit sign-off before anyone writes that migration.
+4. **`/profile` reads `GET /api/settings`.** That endpoint sits on `rootRouter` above the `secretMiddleware`-gated `/api/v1`, so a browser session can call it directly, and it already returns user, perms and map config. No new endpoint is needed for this plan.
+
+## Global Constraints
+
+- Every route is its own lazy chunk. The map route is the only one that may ever carry MapLibre or deck.gl.
+- Mobile first. The bottom nav is the primary navigation; a wider viewport may add to it but must not require it.
+- `app/` is strict TypeScript. `tsconfig.app.json` deliberately does not map `@components`, `@features` or `@store`; those aliases belong to 1.0 and must not resolve here.
+- Nothing in `src/` or `server/src/` changes except `clientRouter.js` and the files Task 6 names.
+- Prose in commits and PRs carries no em dashes, no bold, no inline bulleted headers, and never refers to the maintainer by name.
+- The pre-commit hook runs `biome check` and `tsc -p tsconfig.app.json --noEmit` and blocks on either.
+- `biome.json` rejects `//` comments and silently falls back to defaults if it fails to parse.
+
+---
+
+## File Structure
+
+```
+app/
+ main.tsx existing entry, gains the router provider
+ App.tsx existing, becomes the router tree
+ routes.tsx route table, every element lazy
+ layout/
+ Shell.tsx persistent chrome: outlet + bottom nav
+ BottomNav.tsx Map / Filters / Alerts / Me
+ session/
+ types.ts the shape of GET /api/settings that we rely on
+ useSession.ts fetch + cache the session payload
+ pages/
+ Hub.tsx /
+ MapPage.tsx /map placeholder until plan 4
+ FiltersPage.tsx /filters placeholder until plan 5
+ AlertsPage.tsx /alerts placeholder until plan 5
+ Profile.tsx /profile
+ Locales.tsx /locales placeholder
+ Playground.tsx /playground placeholder, admin gated
+ NotFound.tsx catch-all
+server/src/routes/clientRouter.js gains the 2.0 route table and shell selection
+server/src/db/migrations/ one migration adding the flag column
+```
+
+---
+
+## Task 1: Route table with lazy chunks
+
+**Files:**
+- Create: `app/routes.tsx`, `app/pages/Hub.tsx`, `app/pages/MapPage.tsx`, `app/pages/FiltersPage.tsx`, `app/pages/AlertsPage.tsx`, `app/pages/Profile.tsx`, `app/pages/Locales.tsx`, `app/pages/Playground.tsx`, `app/pages/NotFound.tsx`
+- Modify: `app/App.tsx`, `app/main.tsx`
+- Test: `app/routes.test.tsx`
+
+**Interfaces:**
+- Produces: `ROUTES`, an array consumed by Task 2's shell and asserted against by Task 6's server route table.
+
+- [ ] **Step 1: Install the test dependencies**
+
+```bash
+bun add -d @testing-library/react @testing-library/dom happy-dom
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `app/routes.test.tsx`:
+
+```tsx
+import { expect, test } from 'bun:test'
+import { ROUTES } from './routes'
+
+test('every spec route is present exactly once', () => {
+ const paths = ROUTES.map((route) => route.path).sort()
+ expect(paths).toEqual(
+ [
+ '*',
+ '/',
+ '/alerts',
+ '/filters',
+ '/locales',
+ '/map',
+ '/playground',
+ '/profile',
+ ].sort(),
+ )
+})
+
+test('every route element is lazy so it becomes its own chunk', () => {
+ for (const route of ROUTES) {
+ expect(typeof route.lazy).toBe('function')
+ }
+})
+```
+
+- [ ] **Step 3: Run it to verify it fails**
+
+```bash
+bun test app/routes.test.tsx
+```
+
+Expected: FAIL, `Cannot find module './routes'`.
+
+- [ ] **Step 4: Write the route table**
+
+Create `app/routes.tsx`:
+
+```tsx
+import type { RouteObject } from 'react-router'
+
+/**
+ * Every route is lazy so the bundler gives each one its own chunk. The map
+ * route is the only one that will ever pull in MapLibre and deck.gl, and that
+ * only holds if nothing here imports a page eagerly.
+ */
+export const ROUTES: RouteObject[] = [
+ { path: '/', lazy: async () => ({ Component: (await import('./pages/Hub')).Hub }) },
+ { path: '/map', lazy: async () => ({ Component: (await import('./pages/MapPage')).MapPage }) },
+ { path: '/filters', lazy: async () => ({ Component: (await import('./pages/FiltersPage')).FiltersPage }) },
+ { path: '/alerts', lazy: async () => ({ Component: (await import('./pages/AlertsPage')).AlertsPage }) },
+ { path: '/profile', lazy: async () => ({ Component: (await import('./pages/Profile')).Profile }) },
+ { path: '/locales', lazy: async () => ({ Component: (await import('./pages/Locales')).Locales }) },
+ { path: '/playground', lazy: async () => ({ Component: (await import('./pages/Playground')).Playground }) },
+ { path: '*', lazy: async () => ({ Component: (await import('./pages/NotFound')).NotFound }) },
+]
+```
+
+- [ ] **Step 5: Write the placeholder pages**
+
+Each page is a named export so the lazy imports above resolve. Create all eight with this shape, changing the name and copy:
+
+```tsx
+export function MapPage() {
+ return (
+
+
Map
+
The map arrives in a later plan.
+
+ )
+}
+```
+
+`Hub.tsx` and `Profile.tsx` are replaced wholesale in Tasks 4 and 5, so keep them equally thin for now. `NotFound.tsx` says the page does not exist and links to `/`.
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+```bash
+bun test app/routes.test.tsx
+```
+
+Expected: PASS, 2 tests.
+
+- [ ] **Step 7: Wire the router into the entry**
+
+`app/App.tsx` becomes the router tree. `app/main.tsx` renders `` as it already does, so only `App.tsx` changes:
+
+```tsx
+import { RouterProvider, createBrowserRouter } from 'react-router'
+import { ROUTES } from './routes'
+
+const router = createBrowserRouter(ROUTES)
+
+export function App() {
+ return
+}
+```
+
+- [ ] **Step 8: Confirm the chunks actually split**
+
+```bash
+bun run build && ls dist/ | grep -cE '^(Hub|MapPage|FiltersPage)'
+```
+
+Expected: a non-zero count, and `dist/app.html` still present. If the pages landed in one chunk, the `manualChunks` rule in `vite.config.js` is capturing them; report that rather than working around it, because that rule is load-bearing for the 1.0 stylesheet split.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add app/ package.json bun.lock
+git commit -m "feat(app): add the 2.0 route table with lazy chunks"
+```
+
+---
+
+## Task 2: Shell layout and bottom nav
+
+**Files:**
+- Create: `app/layout/Shell.tsx`, `app/layout/BottomNav.tsx`
+- Modify: `app/routes.tsx`
+- Test: `app/layout/BottomNav.test.tsx`
+
+**Interfaces:**
+- Consumes: `ROUTES` from Task 1.
+- Produces: `Shell`, wrapping every route as react-router's layout route.
+
+- [ ] **Step 1: Configure the DOM test environment**
+
+Create `bunfig.toml` at the repo root if it does not exist, or add to it:
+
+```toml
+[test]
+preload = ["./app/test-setup.ts"]
+```
+
+Create `app/test-setup.ts`:
+
+```ts
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+
+GlobalRegistrator.register()
+```
+
+Install the registrator:
+
+```bash
+bun add -d @happy-dom/global-registrator
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `app/layout/BottomNav.test.tsx`:
+
+```tsx
+import { expect, test } from 'bun:test'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { BottomNav } from './BottomNav'
+
+test('shows the four primary destinations in order', () => {
+ render(
+
+
+ ,
+ )
+ const labels = screen.getAllByRole('link').map((link) => link.textContent)
+ expect(labels).toEqual(['Map', 'Filters', 'Alerts', 'Me'])
+})
+
+test('marks the active destination for assistive tech', () => {
+ render(
+
+
+ ,
+ )
+ const active = screen.getByRole('link', { name: 'Filters' })
+ expect(active.getAttribute('aria-current')).toBe('page')
+})
+```
+
+`toHaveAttribute` is a jest-dom matcher and is NOT available in `bun:test`; verified by running it, and it fails. Read the attribute directly instead, which needs no extra dependency. `NavLink` applies `aria-current="page"` itself when the route matches, confirmed in `react-router@8.3.0` at `dist/development/lib/dom/lib.js:372`, so there is nothing to pass explicitly.
+
+- [ ] **Step 3: Run it to verify it fails**
+
+```bash
+bun test app/layout/BottomNav.test.tsx
+```
+
+Expected: FAIL, cannot find `./BottomNav`.
+
+- [ ] **Step 4: Write the nav**
+
+Create `app/layout/BottomNav.tsx`. Use `NavLink`, which sets `aria-current="page"` itself when the route matches:
+
+```tsx
+import { NavLink } from 'react-router'
+
+const DESTINATIONS = [
+ { to: '/map', label: 'Map' },
+ { to: '/filters', label: 'Filters' },
+ { to: '/alerts', label: 'Alerts' },
+ { to: '/profile', label: 'Me' },
+] as const
+
+export function BottomNav() {
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+```bash
+bun test app/layout/BottomNav.test.tsx
+```
+
+Expected: PASS, 2 tests.
+
+- [ ] **Step 6: Write the shell and make it the layout route**
+
+Create `app/layout/Shell.tsx`:
+
+```tsx
+import { Outlet } from 'react-router'
+import { BottomNav } from './BottomNav'
+
+export function Shell() {
+ return (
+
+
+
+
+
+
+ )
+}
+```
+
+In `app/routes.tsx`, wrap the existing array as the `children` of one layout route whose `Component` is `Shell`. Keep `ROUTES` exported with the same shape the Task 1 test asserts, and export the nested table separately as `ROUTER_ROUTES` for `createBrowserRouter`. Update `App.tsx` to use `ROUTER_ROUTES`.
+
+- [ ] **Step 7: Run the full suite**
+
+```bash
+bun test
+```
+
+Expected: Task 1's route tests still pass unchanged, plus the two nav tests.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add app/ bunfig.toml package.json bun.lock
+git commit -m "feat(app): add the shell layout and mobile bottom nav"
+```
+
+---
+
+## Task 3: Session bootstrap
+
+**Files:**
+- Create: `app/session/types.ts`, `app/session/useSession.ts`
+- Test: `app/session/useSession.test.ts`
+
+**Interfaces:**
+- Produces: `useSession()` returning `{ status, data, error }`, consumed by Tasks 4 and 5.
+
+- [ ] **Step 1: Write the types we actually rely on**
+
+Create `app/session/types.ts`. Deliberately narrow: describe only the fields this plan reads, so a change elsewhere in the payload does not break the build.
+
+```ts
+export interface SessionUser {
+ loggedIn: boolean
+ username?: string
+ perms: Record
+}
+
+export interface SessionSettings {
+ user: SessionUser
+}
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `app/session/useSession.test.ts`:
+
+```ts
+import { afterEach, expect, mock, test } from 'bun:test'
+import { fetchSession } from './useSession'
+
+const originalFetch = globalThis.fetch
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+test('returns the parsed payload on success', async () => {
+ globalThis.fetch = mock(async () =>
+ new Response(JSON.stringify({ user: { loggedIn: true, perms: {} } }), {
+ status: 200,
+ }),
+ ) as typeof fetch
+ const settings = await fetchSession()
+ expect(settings.user.loggedIn).toBe(true)
+})
+
+test('throws with the status when the request fails', async () => {
+ globalThis.fetch = mock(async () => new Response('nope', { status: 500 })) as typeof fetch
+ expect(fetchSession()).rejects.toThrow('500')
+})
+```
+
+- [ ] **Step 3: Run it to verify it fails**
+
+```bash
+bun test app/session/useSession.test.ts
+```
+
+Expected: FAIL, cannot find `./useSession`.
+
+- [ ] **Step 4: Implement the fetch and the hook**
+
+Create `app/session/useSession.ts` with `fetchSession()` doing a credentialed `GET /api/settings` and throwing `new Error(\`GET /api/settings failed: ${response.status}\`)` on a non-ok response, plus a `useSession()` hook holding `status`, `data` and `error` in state and calling `fetchSession` once on mount. Send `credentials: 'same-origin'` so the session cookie travels.
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+```bash
+bun test app/session/useSession.test.ts
+```
+
+Expected: PASS, 2 tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/session/
+git commit -m "feat(app): bootstrap the session from the settings endpoint"
+```
+
+---
+
+## Task 4: The hub
+
+**Files:**
+- Modify: `app/pages/Hub.tsx`
+- Test: `app/pages/Hub.test.tsx`
+
+**Interfaces:**
+- Consumes: nothing. The hub is always on for every operator and must render before the session resolves.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `app/pages/Hub.test.tsx` asserting the hub links to `/map`, `/filters`, `/alerts` and `/profile`, and that it renders without a session (no fetch mocked at all). Use `MemoryRouter` as in Task 2.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+bun test app/pages/Hub.test.tsx
+```
+
+Expected: FAIL, the placeholder has no links.
+
+- [ ] **Step 3: Write the hub**
+
+Navigation to the other surfaces, nothing else. The spec is explicit that this is a hub and not a marketing page, and that the news feed is a later phase. Do not add a feed, a hero, or copy about the project.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/pages/
+git commit -m "feat(app): build the hub at the root route"
+```
+
+---
+
+## Task 5: Profile
+
+**Files:**
+- Modify: `app/pages/Profile.tsx`
+- Test: `app/pages/Profile.test.tsx`
+
+**Interfaces:**
+- Consumes: `useSession()` from Task 3.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `app/pages/Profile.test.tsx` covering three states, since all three are reachable and only one is the happy path:
+
+1. session still loading renders a loading affordance,
+2. logged out renders a prompt to sign in and does not render account details,
+3. logged in renders the username.
+
+Mock `globalThis.fetch` per case exactly as Task 3 does, and restore it in `afterEach`.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Expected: FAIL, the placeholder renders none of these.
+
+- [ ] **Step 3: Write the page**
+
+Render the three states. Show username and the permission list from the session payload. Do not build the reset action or linked-accounts management in this plan; both need endpoints that do not exist yet, and inventing them here would commit the transport design that session 3 owns. Leave a clearly labelled section noting they arrive later.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Expected: PASS, 3 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/pages/
+git commit -m "feat(app): render the profile page from the session payload"
+```
+
+---
+
+## Task 6: Server-side shell selection
+
+**Files:**
+- Modify: `server/src/routes/clientRouter.js`
+- Create: one migration under `server/src/db/migrations/`
+- Test: `server/test/clientRouter.test.js`
+
+**Interfaces:**
+- Consumes: the route paths from Task 1.
+
+- [ ] **Step 1: Find the migration convention**
+
+```bash
+ls server/src/db/migrations/ | tail -5
+```
+
+Read the most recent one and match its filename format and export shape exactly. Do not invent a format.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `server/test/clientRouter.test.js` asserting that the module exports a helper which, given a request-like object, returns `app.html` when the user carries the 2.0 flag and `index.html` when they do not or when there is no user at all. Export that helper from `clientRouter.js` so it can be tested without booting Express; requiring the whole server would start the database singleton and kill the test runner.
+
+- [ ] **Step 3: Run it to verify it fails**
+
+```bash
+bun test server/test/clientRouter.test.js
+```
+
+Expected: FAIL, the helper is not exported.
+
+- [ ] **Step 4: Add the migration**
+
+One column on the users table, defaulting to false so every existing row keeps 1.0. Name it for what it does rather than for a version number, so it reads sensibly after 2.0 stops being new.
+
+- [ ] **Step 5: Implement selection**
+
+Add the 2.0 paths from Task 1 to the router's route list, keeping every existing 1.0 path so current deep links keep working. Note that `/` means the map in 1.0 and the hub in 2.0; both shells claim the same path and the flag is what disambiguates. Serve `app.html` or `index.html` from the same `dist` directory logic already there, including the `NODE_CONFIG_ENV` suffix.
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Expected: PASS.
+
+- [ ] **Step 7: Run everything**
+
+```bash
+bun test && bun run typecheck && bun run lint && bun run build
+```
+
+Expected: all clean. Confirm `dist/index.html` and `dist/app.html` both exist and still reference different stylesheets, with `@layer base` absent from the 1.0 one. That invariant was won in the foundation plan and this task touches the same build.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add server/ && git commit -m "feat(server): serve the 2.0 shell to flagged users"
+```
+
+---
+
+## Done criteria
+
+```bash
+bun test
+bun run typecheck
+bun run lint
+bun run build
+```
+
+All four succeed. Then:
+
+- `dist/app.html` exists and each page from Task 1 is its own chunk in `dist/`.
+- `dist/index.html` still loads a stylesheet containing no `@layer base`.
+- A user row without the flag is served `index.html`; a row with it is served `app.html`.
+- Visiting `/map`, `/filters`, `/alerts` and `/profile` in the 2.0 shell renders each page with the bottom nav present.
+
+## What this plan does not do
+
+- No map, no filters UI, no alerts UI. Those routes render placeholders.
+- No shadcn and no design tokens. Plan 3 owns the design system; the styling here is plain Tailwind and is expected to be replaced.
+- Nothing in the spec's "Deleted by this IA" list is deleted. See assumption 3.
+- No Playwright. Three of the spec's four flows need features later plans build; the cold-load flow is worth adding once there is something to load.
+- No profile reset action and no linked-account management, per Task 5.
diff --git a/package.json b/package.json
index 9c7a8334e..a5ec3bab4 100644
--- a/package.json
+++ b/package.json
@@ -177,12 +177,15 @@
"@biomejs/biome": "2.5.10",
"@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.2.2",
+ "@happy-dom/global-registrator": "20.11.6",
"@rm/types": "*",
"@rm/vite-plugins": "*",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@sentry/vite-plugin": "4.6.2",
"@tailwindcss/vite": "4.3.3",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
"@types/dlv": "^1.1.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.20.1",
@@ -192,6 +195,7 @@
"bun-types": "1.4.0",
"commitizen": "^4.3.0",
"cz-conventional-commit": "^1.0.6",
+ "happy-dom": "20.11.6",
"husky": "^8.0.1",
"monaco-editor": "^0.41.0",
"nodemon": "3.1.14",
diff --git a/server/src/db/migrations/20260824021800_add_shell_preference_to_user_table.cjs b/server/src/db/migrations/20260824021800_add_shell_preference_to_user_table.cjs
new file mode 100644
index 000000000..6c60cabbb
--- /dev/null
+++ b/server/src/db/migrations/20260824021800_add_shell_preference_to_user_table.cjs
@@ -0,0 +1,24 @@
+/*
+ * 2.0 does not support renaming this table. That option was announced as
+ * unsupported well ahead of this release, so the name is fixed here rather
+ * than read from config the way the pre-2.0 migrations do. An install still
+ * relying on a renamed table fails loudly at migrate time, which is the
+ * intended outcome rather than a silent partial upgrade.
+ */
+const USER_TABLE = 'users'
+
+/**
+ * @param {import("knex").Knex} knex
+ */
+exports.up = async (knex) =>
+ knex.schema.table(USER_TABLE, (table) => {
+ table.boolean('useAppShell').notNullable().defaultTo(false)
+ })
+
+/**
+ * @param {import("knex").Knex} knex
+ */
+exports.down = async (knex) =>
+ knex.schema.table(USER_TABLE, (table) => {
+ table.dropColumn('useAppShell')
+ })
diff --git a/server/src/index.js b/server/src/index.js
index 11bbe4a72..5e06ddf71 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -67,7 +67,9 @@ const startServer = async () => {
app.use(
loggerMiddleware,
noSourceMapMiddleware,
- express.static(distDir, { dotfiles: 'allow' }),
+ // `index: false` keeps serve-static from answering a bare `/` with the 1.0
+ // shell off disk, which would shadow the router that picks a shell per user.
+ express.static(distDir, { dotfiles: 'allow', index: false }),
sessionMiddleware(),
compression(),
express.json({
diff --git a/server/src/routes/clientRouter.js b/server/src/routes/clientRouter.js
index 0b31d3fb4..a31cf7296 100644
--- a/server/src/routes/clientRouter.js
+++ b/server/src/routes/clientRouter.js
@@ -4,7 +4,21 @@ const path = require('path')
const clientRouter = express.Router()
-const CLIENT_ROUTES = [
+/**
+ * The users table column that decides which built shell a person is served.
+ * Named for what it selects rather than for a version number, since "2.0"
+ * stops meaning anything the moment 2.1 exists.
+ */
+const SHELL_FLAG_COLUMN = 'useAppShell'
+
+const LEGACY_SHELL = 'index.html'
+const MODERN_SHELL = 'app.html'
+
+/**
+ * Paths the 1.0 client owns. Deep links of the `/@/:lat/:lon/:zoom` and
+ * `/id/:category/:id` shapes are in the wild, so nothing here may be dropped.
+ */
+const LEGACY_ROUTES = [
'/',
'/login',
'/blocked/:info',
@@ -23,15 +37,90 @@ const CLIENT_ROUTES = [
'/error/:message',
]
-clientRouter.get(CLIENT_ROUTES, (_req, res) => {
- res.sendFile(
- path.join(
- __dirname,
- `../../../dist${
- process.env.NODE_CONFIG_ENV ? `-${process.env.NODE_CONFIG_ENV}` : ''
- }/index.html`,
- ),
- )
+/**
+ * Paths the 2.0 client owns, mirroring `app/routes.tsx`. `/`, `/locales` and
+ * `/playground` are claimed by both clients; the per-user flag, not the path,
+ * is what disambiguates them.
+ */
+const MODERN_ROUTES = [
+ '/',
+ '/map',
+ '/filters',
+ '/alerts',
+ '/profile',
+ '/locales',
+ '/playground',
+]
+
+const CLIENT_ROUTES = [...new Set([...LEGACY_ROUTES, ...MODERN_ROUTES])]
+
+/**
+ * Paths the 2.0 client has no route for, so it would answer them with its
+ * catch-all NotFound. They are derived rather than hand listed, since a third
+ * literal list would drift the first time either table above changes.
+ */
+const LEGACY_ONLY_ROUTES = LEGACY_ROUTES.filter(
+ (route) => !MODERN_ROUTES.includes(route),
+)
+
+/**
+ * Which shell this request should be served.
+ *
+ * Anything falsy means the 1.0 shell, which covers the anonymous visitor, the
+ * user whose row predates the migration, and mysql handing back tinyint 0.
+ *
+ * @param {{ user?: Record }} [req]
+ * @returns {string}
+ */
+function resolveShell(req) {
+ return req?.user?.[SHELL_FLAG_COLUMN] ? MODERN_SHELL : LEGACY_SHELL
+}
+
+/**
+ * Absolute path of a named shell file, honouring the NODE_CONFIG_ENV suffix on
+ * the dist directory that a multi instance install relies on.
+ *
+ * @param {string} shell
+ * @returns {string}
+ */
+function shellPath(shell) {
+ const suffix = process.env.NODE_CONFIG_ENV
+ ? `-${process.env.NODE_CONFIG_ENV}`
+ : ''
+ return path.join(__dirname, `../../../dist${suffix}`, shell)
+}
+
+/**
+ * Absolute path of the shell this request should be served.
+ *
+ * @param {{ user?: Record }} [req]
+ * @returns {string}
+ */
+function resolveShellPath(req) {
+ return shellPath(resolveShell(req))
+}
+
+// A path only 1.0 implements ignores the flag, because serving 2.0 there would
+// hand a flagged user its NotFound page for a link that works for everyone
+// else. The two sets are disjoint, so registration order does not matter.
+clientRouter.get(LEGACY_ONLY_ROUTES, (_req, res) => {
+ res.sendFile(shellPath(LEGACY_SHELL))
+})
+
+clientRouter.get(MODERN_ROUTES, (req, res) => {
+ res.sendFile(resolveShellPath(req))
})
-module.exports = { clientRouter }
+module.exports = {
+ clientRouter,
+ CLIENT_ROUTES,
+ LEGACY_ROUTES,
+ LEGACY_ONLY_ROUTES,
+ MODERN_ROUTES,
+ LEGACY_SHELL,
+ MODERN_SHELL,
+ SHELL_FLAG_COLUMN,
+ resolveShell,
+ resolveShellPath,
+ shellPath,
+}
diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js
index f6a5726ff..2d94c45e1 100644
--- a/server/src/routes/rootRouter.js
+++ b/server/src/routes/rootRouter.js
@@ -13,6 +13,7 @@ const { clientRouter } = require('./clientRouter')
const { apiRouter } = require('./api')
const { areaPerms } = require('../utils/areaPerms')
const { getServerSettings } = require('../utils/getServerSettings')
+const { refreshSessionUser } = require('../utils/refreshSessionUser')
const {
hasAnyPokestopPermission,
} = require('../utils/hasAnyPokestopPermission')
@@ -184,9 +185,7 @@ rootRouter.get('/api/settings', async (req, res, next) => {
req.session.save()
}
}
- if (user.data !== undefined) {
- req.user.data = user.data
- }
+ refreshSessionUser(req, user)
}
} catch (e) {
log.warn(TAGS.session, 'Issue finding user, User ID:', req?.user?.id, e)
diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js
index 57d49e47a..1b24c9f51 100644
--- a/server/src/services/LocalClient.js
+++ b/server/src/services/LocalClient.js
@@ -166,6 +166,12 @@ class LocalClient extends AuthClient {
user.discordId = userExists.discordId
user.telegramId = userExists.telegramId
user.webhookStrategy = userExists.webhookStrategy
+ // Discord and Telegram spread the whole row onto the session
+ // user, so they pick this up for free. Local auth copies fields
+ // one at a time, so an omission here is invisible until someone
+ // notices that flagging a local account onto the 2.0 shell
+ // silently does nothing.
+ user.useAppShell = userExists.useAppShell
user.data = userExists.data
user.status = userExists.data
? (typeof userExists.data === 'string'
diff --git a/server/src/utils/refreshSessionUser.js b/server/src/utils/refreshSessionUser.js
new file mode 100644
index 000000000..75e54d4e3
--- /dev/null
+++ b/server/src/utils/refreshSessionUser.js
@@ -0,0 +1,46 @@
+// @ts-check
+
+const { SHELL_FLAG_COLUMN } = require('../routes/clientRouter')
+
+/**
+ * Columns re-read from the users table on session init and copied back onto
+ * the logged in user.
+ *
+ * Passport serializes the whole user row into the session and deserializes it
+ * without touching the database, so a column that is not refreshed here keeps
+ * the value it had at login until the person logs out. Sessions live in the
+ * database and survive a restart, so that is indefinitely.
+ */
+const REFRESHED_COLUMNS = ['data', SHELL_FLAG_COLUMN]
+
+/**
+ * Copies the refreshed columns from a freshly fetched row onto both copies of
+ * the logged in user: `req.user`, which serves the rest of this request, and
+ * the object stored in the session, which is what every later request is
+ * deserialized from.
+ *
+ * Patching only `req.user` would be lost at the end of the request, since the
+ * deserializer hands back a copy rather than the stored object.
+ *
+ * @param {{ user?: Record, session?: Record }} req
+ * @param {Record} row
+ */
+function refreshSessionUser(req, row) {
+ const storedUser = req.session?.passport?.user
+ const targets = [req.user, storedUser].filter(Boolean)
+
+ let changedStored = false
+ REFRESHED_COLUMNS.forEach((column) => {
+ if (row?.[column] === undefined) return
+ targets.forEach((target) => {
+ target[column] = row[column]
+ })
+ if (storedUser) changedStored = true
+ })
+
+ if (changedStored) {
+ req.session.save()
+ }
+}
+
+module.exports = { refreshSessionUser, REFRESHED_COLUMNS }
diff --git a/server/test/clientRouter.test.js b/server/test/clientRouter.test.js
new file mode 100644
index 000000000..42c26482c
--- /dev/null
+++ b/server/test/clientRouter.test.js
@@ -0,0 +1,174 @@
+const http = require('http')
+const path = require('path')
+const { afterEach, expect, test } = require('bun:test')
+const express = require('express')
+
+const {
+ clientRouter,
+ CLIENT_ROUTES,
+ LEGACY_ONLY_ROUTES,
+ MODERN_ROUTES,
+ LEGACY_SHELL,
+ MODERN_SHELL,
+ SHELL_FLAG_COLUMN,
+ resolveShell,
+ resolveShellPath,
+} = require('../src/routes/clientRouter')
+
+const originalConfigEnv = process.env.NODE_CONFIG_ENV
+
+afterEach(() => {
+ if (originalConfigEnv === undefined) {
+ delete process.env.NODE_CONFIG_ENV
+ } else {
+ process.env.NODE_CONFIG_ENV = originalConfigEnv
+ }
+})
+
+test('an anonymous visitor gets the 1.0 shell', () => {
+ expect(resolveShell({})).toBe(LEGACY_SHELL)
+ expect(resolveShell({ user: undefined })).toBe(LEGACY_SHELL)
+ expect(resolveShell(undefined)).toBe(LEGACY_SHELL)
+})
+
+test('a logged in user without the flag gets the 1.0 shell', () => {
+ expect(resolveShell({ user: { id: 1 } })).toBe(LEGACY_SHELL)
+ expect(resolveShell({ user: { id: 1, [SHELL_FLAG_COLUMN]: false } })).toBe(
+ LEGACY_SHELL,
+ )
+})
+
+test('mysql returns tinyint(1), so 0 must read as off and 1 as on', () => {
+ expect(resolveShell({ user: { id: 1, [SHELL_FLAG_COLUMN]: 0 } })).toBe(
+ LEGACY_SHELL,
+ )
+ expect(resolveShell({ user: { id: 1, [SHELL_FLAG_COLUMN]: 1 } })).toBe(
+ MODERN_SHELL,
+ )
+})
+
+test('a user carrying the flag gets the 2.0 shell', () => {
+ expect(resolveShell({ user: { id: 1, [SHELL_FLAG_COLUMN]: true } })).toBe(
+ MODERN_SHELL,
+ )
+})
+
+test('the served path keeps the NODE_CONFIG_ENV dist suffix', () => {
+ delete process.env.NODE_CONFIG_ENV
+ expect(resolveShellPath({})).toBe(
+ path.join(__dirname, '../../dist/index.html'),
+ )
+
+ process.env.NODE_CONFIG_ENV = 'beta'
+ expect(resolveShellPath({})).toBe(
+ path.join(__dirname, '../../dist-beta/index.html'),
+ )
+ expect(resolveShellPath({ user: { [SHELL_FLAG_COLUMN]: true } })).toBe(
+ path.join(__dirname, '../../dist-beta/app.html'),
+ )
+})
+
+test('every 1.0 deep link stays in the route list', () => {
+ const legacy = [
+ '/',
+ '/login',
+ '/blocked/:info',
+ '/@/:lat/:lon',
+ '/@/:lat/:lon/:zoom',
+ '/id/:category/:id',
+ '/id/:category/:id/:zoom',
+ '/304',
+ '/404',
+ '/500',
+ '/reset',
+ '/playground',
+ '/locales',
+ '/data-management',
+ '/error',
+ '/error/:message',
+ ]
+ legacy.forEach((route) => expect(CLIENT_ROUTES).toContain(route))
+})
+
+test('the 2.0 route table is served too, with no duplicates', () => {
+ const modern = [
+ '/',
+ '/map',
+ '/filters',
+ '/alerts',
+ '/profile',
+ '/locales',
+ '/playground',
+ ]
+ modern.forEach((route) => expect(CLIENT_ROUTES).toContain(route))
+ expect(CLIENT_ROUTES.length).toBe(new Set(CLIENT_ROUTES).size)
+})
+
+/**
+ * The tests above only check list membership, which is what let the router
+ * serve the 2.0 shell on paths only the 1.0 client implements. These drive the
+ * real router over HTTP so the assertion is the shell a request actually gets.
+ */
+
+const SAMPLE_PARAMS = {
+ info: 'banned',
+ lat: '40.7',
+ lon: '-74.0',
+ zoom: '15',
+ category: 'pokemon',
+ id: '25',
+ message: 'boom',
+}
+
+/** @param {string} route */
+const concreteUrl = (route) =>
+ route.replace(/:(\w+)/g, (_full, name) => SAMPLE_PARAMS[name])
+
+/**
+ * Boots the router with `sendFile` replaced by a reply naming the file, so a
+ * request reports which shell it was routed to without needing a built dist.
+ */
+async function withRouter(run) {
+ const app = express()
+ app.use((req, res, next) => {
+ if (req.headers.flagged === 'yes') {
+ req.user = { id: 1, [SHELL_FLAG_COLUMN]: 1 }
+ }
+ res.sendFile = (filePath) => res.status(200).send(path.basename(filePath))
+ next()
+ })
+ app.use(clientRouter)
+
+ const server = http.createServer(app)
+ await new Promise((resolve) => server.listen(0, resolve))
+ const { port } = server.address()
+ try {
+ await run(async (route, flagged) => {
+ const res = await fetch(`http://127.0.0.1:${port}${concreteUrl(route)}`, {
+ headers: flagged ? { flagged: 'yes' } : {},
+ })
+ return res.text()
+ })
+ } finally {
+ await new Promise((resolve) => server.close(resolve))
+ }
+}
+
+test('a path only 1.0 implements serves the 1.0 shell even when flagged', async () => {
+ expect(LEGACY_ONLY_ROUTES.length).toBe(13)
+ await withRouter(async (request) => {
+ for (const route of LEGACY_ONLY_ROUTES) {
+ expect(await request(route, false)).toBe(LEGACY_SHELL)
+ expect(await request(route, true)).toBe(LEGACY_SHELL)
+ }
+ })
+})
+
+test('a path both clients implement follows the flag', async () => {
+ await withRouter(async (request) => {
+ for (const route of MODERN_ROUTES) {
+ expect(await request(route, false)).toBe(LEGACY_SHELL)
+ expect(await request(route, true)).toBe(MODERN_SHELL)
+ }
+ })
+})
diff --git a/server/test/shellFlag.test.js b/server/test/shellFlag.test.js
new file mode 100644
index 000000000..6376d5c49
--- /dev/null
+++ b/server/test/shellFlag.test.js
@@ -0,0 +1,56 @@
+const assert = require('node:assert/strict')
+const { readFileSync } = require('node:fs')
+const { test } = require('bun:test')
+
+const { SHELL_FLAG_COLUMN } = require('../src/routes/clientRouter')
+
+/**
+ * The bug this guards against: Discord and Telegram build their session user
+ * by spreading the whole users-table row, so a new column reaches them for
+ * free. LocalClient.authHandler instead copies fields onto the user object one
+ * at a time, so a new column silently never arrives unless someone remembers
+ * to add the line. Flagging a local account onto the 2.0 shell did nothing,
+ * nothing errored, and the column held the right value the whole time.
+ *
+ * Driving authHandler end to end would need the login to reach the field-copy
+ * branch, which first runs areaPerms/webhookPerms/scannerPerms against live
+ * app config, including a request-time `areas` value only populated during
+ * real server boot. That is a lot of scaffolding for one field assignment, so
+ * this pins the invariant structurally instead.
+ *
+ * The invariant is deliberately "spreads the row OR copies the flag", not
+ * "keeps using a spread". Rewriting a client to assign fields explicitly is a
+ * legitimate change as long as the flag comes along, and a test that failed on
+ * correct code would just get deleted the first time it was inconvenient.
+ */
+
+/** @param {string} name */
+const sourceOf = (name) =>
+ readFileSync(require.resolve(`../src/services/${name}`), 'utf8')
+
+const CLIENTS = ['LocalClient', 'DiscordClient', 'TelegramClient']
+
+// Specifically the users-table row, which is the thing that carries columns.
+// Matching a looser `...user` would also match LocalClient's `...user.perms`,
+// and the general test below would then pass for a client that does not carry
+// the flag at all.
+const spreadsRow = /\.\.\.\s*userExists\b/
+const copiesFlag = new RegExp(
+ `\\b${SHELL_FLAG_COLUMN}\\s*[:=]\\s*\\w+\\.${SHELL_FLAG_COLUMN}\\b`,
+)
+
+CLIENTS.forEach((name) => {
+ test(`${name} carries the shell flag onto the session user`, () => {
+ const source = sourceOf(name)
+ assert.ok(
+ spreadsRow.test(source) || copiesFlag.test(source),
+ `${name} neither spreads the user row nor copies ${SHELL_FLAG_COLUMN} explicitly, so a person flagged onto the 2.0 shell would silently stay on 1.0`,
+ )
+ })
+})
+
+test('local login copies the flag explicitly, since it does not spread the row', () => {
+ // LocalClient is the one client that builds its session user field by field,
+ // which is why it needed the assignment the other two get for free.
+ assert.match(sourceOf('LocalClient'), copiesFlag)
+})
diff --git a/server/test/shellFlagRefresh.test.js b/server/test/shellFlagRefresh.test.js
new file mode 100644
index 000000000..2ff91ffc6
--- /dev/null
+++ b/server/test/shellFlagRefresh.test.js
@@ -0,0 +1,95 @@
+const http = require('http')
+const { afterAll, beforeAll, expect, test } = require('bun:test')
+const express = require('express')
+const session = require('express-session')
+const passport = require('passport')
+
+const {
+ SHELL_FLAG_COLUMN,
+ resolveShell,
+} = require('../src/routes/clientRouter')
+const { refreshSessionUser } = require('../src/utils/refreshSessionUser')
+
+// Requiring the middleware registers the serializer pair this depends on.
+require('../src/middleware/passport')
+
+/**
+ * Flipping the users table column used to do nothing until the person logged
+ * out, because passport serializes the whole row into the session and
+ * deserializes it without re-reading the database.
+ *
+ * The subtlety that makes this worth an end to end test: the deserializer
+ * hands back a spread copy, so patching `req.user` alone is discarded when the
+ * request ends and the next page load is served from the stale session again.
+ * A row that changes has to reach the object stored in the session.
+ */
+
+// Stands in for the users table row that /api/settings re-reads.
+const row = { id: 1, perms: { map: true }, [SHELL_FLAG_COLUMN]: 0 }
+
+let server
+let port = 0
+const cookies = []
+
+const request = async (url) => {
+ const res = await fetch(`http://127.0.0.1:${port}${url}`, {
+ headers: cookies.length ? { cookie: cookies.join('; ') } : {},
+ })
+ const setCookies = res.headers.getSetCookie?.() || []
+ setCookies.forEach((cookie) => cookies.push(cookie.split(';')[0]))
+ return res.text()
+}
+
+beforeAll(async () => {
+ const app = express()
+ app.use(session({ secret: 'test', resave: true, saveUninitialized: false }))
+ app.use(passport.initialize())
+ app.use(passport.session())
+
+ app.get('/login', (req, res) => {
+ req.login({ ...row }, () => res.send('ok'))
+ })
+ // The copy back that /api/settings performs after fetching the row.
+ app.get('/settings', (req, res) => {
+ refreshSessionUser(req, row)
+ res.send('ok')
+ })
+ // What serving the HTML shell decides, on a request of its own.
+ app.get('/shell', (req, res) => res.send(resolveShell(req)))
+
+ server = http.createServer(app)
+ await new Promise((resolve) => server.listen(0, resolve))
+ port = server.address().port
+})
+
+afterAll(async () => {
+ await new Promise((resolve) => server.close(resolve))
+})
+
+test('a column flipped after login reaches later requests', async () => {
+ await request('/login')
+ expect(await request('/shell')).toBe('index.html')
+
+ // An operator sets the column while the session is already open.
+ row[SHELL_FLAG_COLUMN] = 1
+ expect(await request('/shell')).toBe('index.html')
+
+ // One settings call later, which every page load makes, the flag is live.
+ await request('/settings')
+ expect(await request('/shell')).toBe('app.html')
+
+ // And setting it back rescues the person without a logout.
+ row[SHELL_FLAG_COLUMN] = 0
+ await request('/settings')
+ expect(await request('/shell')).toBe('index.html')
+})
+
+test('the settings endpoint is what performs the copy back', () => {
+ // The app above is a stand in, so on its own it would keep passing if the
+ // real endpoint stopped refreshing the user. This pins the actual call.
+ const source = require('node:fs').readFileSync(
+ require.resolve('../src/routes/rootRouter.js'),
+ 'utf8',
+ )
+ expect(source).toMatch(/refreshSessionUser\(req,\s*user\)/)
+})
diff --git a/server/test/staticFallthrough.test.js b/server/test/staticFallthrough.test.js
new file mode 100644
index 000000000..bc7567644
--- /dev/null
+++ b/server/test/staticFallthrough.test.js
@@ -0,0 +1,87 @@
+const fs = require('fs')
+const http = require('http')
+const os = require('os')
+const path = require('path')
+const { afterAll, beforeAll, expect, test } = require('bun:test')
+const express = require('express')
+
+const {
+ clientRouter,
+ LEGACY_SHELL,
+ MODERN_SHELL,
+ SHELL_FLAG_COLUMN,
+} = require('../src/routes/clientRouter')
+
+/**
+ * `express.static` is mounted ahead of the router in `server/src/index.js`, and
+ * serve-static answers a bare directory request with `index.html` unless told
+ * otherwise. That made `/` come off disk as the 1.0 shell for everyone, so the
+ * hub at the root was unreachable on a cold load however the flag was set.
+ *
+ * This mirrors that middleware order against a throwaway dist directory: `/`
+ * has to reach the router, and real files still have to be served.
+ */
+
+const ASSET = 'assets/index-abc123.js'
+const ASSET_BODY = 'console.log("hashed asset")'
+
+let distDir = ''
+let server
+let port = 0
+
+beforeAll(async () => {
+ distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactmap-dist-'))
+ fs.mkdirSync(path.join(distDir, 'assets'))
+ fs.writeFileSync(path.join(distDir, LEGACY_SHELL), 'shell off disk')
+ fs.writeFileSync(path.join(distDir, MODERN_SHELL), 'shell off disk')
+ fs.writeFileSync(path.join(distDir, ASSET), ASSET_BODY)
+
+ const app = express()
+ app.use(express.static(distDir, { dotfiles: 'allow', index: false }))
+ app.use((req, res, next) => {
+ if (req.headers.flagged === 'yes') {
+ req.user = { id: 1, [SHELL_FLAG_COLUMN]: 1 }
+ }
+ // Reporting the basename separates a router answer from a disk answer,
+ // since the files above deliberately do not contain their own names.
+ res.sendFile = (filePath) => res.status(200).send(path.basename(filePath))
+ next()
+ })
+ app.use(clientRouter)
+
+ server = http.createServer(app)
+ await new Promise((resolve) => server.listen(0, resolve))
+ port = server.address().port
+})
+
+afterAll(async () => {
+ await new Promise((resolve) => server.close(resolve))
+ fs.rmSync(distDir, { recursive: true, force: true })
+})
+
+/**
+ * @param {string} url
+ * @param {boolean} [flagged]
+ */
+const request = async (url, flagged) => {
+ const res = await fetch(`http://127.0.0.1:${port}${url}`, {
+ headers: flagged ? { flagged: 'yes' } : {},
+ })
+ return res.text()
+}
+
+test('a cold load of / reaches the router rather than the disk', async () => {
+ expect(await request('/')).toBe(LEGACY_SHELL)
+ expect(await request('/', true)).toBe(MODERN_SHELL)
+})
+
+test('hashed assets are still served off disk', async () => {
+ expect(await request(`/${ASSET}`)).toBe(ASSET_BODY)
+})
+
+test('the real middleware stack passes index: false', () => {
+ // The stack above is a rebuild, so on its own it would keep passing if
+ // someone dropped the option from the server. This pins the actual call.
+ const source = fs.readFileSync(require.resolve('../src/index.js'), 'utf8')
+ expect(source).toMatch(/express\.static\([^)]*index:\s*false/)
+})