From 2ee8ae03d06672411160a57024881b33f6c12b7f Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:47:44 -0400 Subject: [PATCH 01/17] docs: plan the 2.0 shell and information architecture Six tasks covering the route table, the mobile bottom nav, session bootstrap, the hub, the profile page, and the server-side flag that decides which shell a request is served. Four judgement calls are recorded in the plan rather than left implicit: the router is react-router since it is already a dependency, component tests run on bun's own runner instead of adding Vitest, the profile reads the existing settings endpoint so nothing here waits on the transport work, and the spec's list of things this IA deletes stays out of scope because those files still serve the shell most users are on. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-24-shell-and-ia.md | 593 ++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-shell-and-ia.md 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. From 8a0faba7f8cd7870758174e42346a539ffa7a5fe Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:49:52 -0400 Subject: [PATCH 02/17] feat(app): add the 2.0 route table with lazy chunks --- app/App.tsx | 11 ++++---- app/pages/AlertsPage.tsx | 8 ++++++ app/pages/FiltersPage.tsx | 8 ++++++ app/pages/Hub.tsx | 8 ++++++ app/pages/Locales.tsx | 8 ++++++ app/pages/MapPage.tsx | 8 ++++++ app/pages/NotFound.tsx | 15 +++++++++++ app/pages/Playground.tsx | 10 +++++++ app/pages/Profile.tsx | 10 +++++++ app/routes.test.tsx | 24 +++++++++++++++++ app/routes.tsx | 55 +++++++++++++++++++++++++++++++++++++++ bun.lock | 35 ++++++++++++++++++++++++- package.json | 3 +++ 13 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 app/pages/AlertsPage.tsx create mode 100644 app/pages/FiltersPage.tsx create mode 100644 app/pages/Hub.tsx create mode 100644 app/pages/Locales.tsx create mode 100644 app/pages/MapPage.tsx create mode 100644 app/pages/NotFound.tsx create mode 100644 app/pages/Playground.tsx create mode 100644 app/pages/Profile.tsx create mode 100644 app/routes.test.tsx create mode 100644 app/routes.tsx diff --git a/app/App.tsx b/app/App.tsx index befb9c562..75a4df74d 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,7 +1,8 @@ +import { createBrowserRouter, RouterProvider } from 'react-router' +import { ROUTES } from './routes' + +const router = createBrowserRouter(ROUTES) + export function App() { - return ( -
-

ReactMap 2.0

-
- ) + 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 ( +
+

Alerts

+

Alerts arrive in a later plan.

+
+ ) +} diff --git a/app/pages/FiltersPage.tsx b/app/pages/FiltersPage.tsx new file mode 100644 index 000000000..50f66f2ed --- /dev/null +++ b/app/pages/FiltersPage.tsx @@ -0,0 +1,8 @@ +export function FiltersPage() { + return ( +
+

Filters

+

Filters arrive in a later plan.

+
+ ) +} diff --git a/app/pages/Hub.tsx b/app/pages/Hub.tsx new file mode 100644 index 000000000..e848b4bfe --- /dev/null +++ b/app/pages/Hub.tsx @@ -0,0 +1,8 @@ +export function Hub() { + return ( +
+

Hub

+

The hub arrives in a later plan.

+
+ ) +} diff --git a/app/pages/Locales.tsx b/app/pages/Locales.tsx new file mode 100644 index 000000000..5db4c7374 --- /dev/null +++ b/app/pages/Locales.tsx @@ -0,0 +1,8 @@ +export function Locales() { + return ( +
+

Locales

+

Locales arrive in a later plan.

+
+ ) +} diff --git a/app/pages/MapPage.tsx b/app/pages/MapPage.tsx new file mode 100644 index 000000000..c99e5828c --- /dev/null +++ b/app/pages/MapPage.tsx @@ -0,0 +1,8 @@ +export function MapPage() { + return ( +
+

Map

+

The map arrives in a later plan.

+
+ ) +} diff --git a/app/pages/NotFound.tsx b/app/pages/NotFound.tsx new file mode 100644 index 000000000..049c6d644 --- /dev/null +++ b/app/pages/NotFound.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router' + +export function NotFound() { + return ( +
+

Page not found

+

+ This page does not exist.{' '} + + Go back home + +

+
+ ) +} diff --git a/app/pages/Playground.tsx b/app/pages/Playground.tsx new file mode 100644 index 000000000..961cd55fd --- /dev/null +++ b/app/pages/Playground.tsx @@ -0,0 +1,10 @@ +export function Playground() { + return ( +
+

Playground

+

+ The playground arrives in a later plan. +

+
+ ) +} diff --git a/app/pages/Profile.tsx b/app/pages/Profile.tsx new file mode 100644 index 000000000..7d43d2960 --- /dev/null +++ b/app/pages/Profile.tsx @@ -0,0 +1,10 @@ +export function Profile() { + return ( +
+

Profile

+

+ The profile arrives 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..f1f9d1c58 --- /dev/null +++ b/app/routes.tsx @@ -0,0 +1,55 @@ +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, + }), + }, +] diff --git a/bun.lock b/bun.lock index 2f1e82de3..f2ad170e3 100644 --- a/bun.lock +++ b/bun.lock @@ -94,6 +94,8 @@ "@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 +105,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", @@ -756,6 +759,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 +799,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 +833,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 +883,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 +935,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 +1093,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 +1113,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 +1139,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 +1315,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 +1583,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 +1803,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 +2191,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 +2237,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 +2845,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/package.json b/package.json index 9c7a8334e..59e533b1f 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,8 @@ "@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 +194,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", From 6ec0952740ffbfddc871b5bad501828aa5052925 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:59:56 -0400 Subject: [PATCH 03/17] feat(app): add the shell layout and mobile bottom nav Wrap the route table in a layout route rendering Shell, which owns the bottom nav and an Outlet for the page content. ROUTES stays flat for Task 1's test; the nested table createBrowserRouter consumes is exported separately as ROUTER_ROUTES. happy-dom needs registering before any test file imports @testing-library/dom, which only bunfig's preload can guarantee, but that preload runs once for the entire bun test process across every workspace in this monorepo, not just app/. Registering happy-dom there unconditionally broke packages/masterfile (CORS), server/test (Response.json resolving to happy-dom's class), and app/build.test.ts (Vite's bundled code branching on typeof document). test-setup.ts instead exports setupDom/teardownDom for a test file's own beforeAll/afterAll, which Bun scopes per file, and BottomNav.test.tsx reads its queries off render()'s return value rather than the testing-library screen singleton, since screen snapshots document at import time and would need the same process-wide registration. --- app/App.tsx | 4 ++-- app/layout/BottomNav.test.tsx | 36 +++++++++++++++++++++++++++++++++++ app/layout/BottomNav.tsx | 26 +++++++++++++++++++++++++ app/layout/Shell.tsx | 13 +++++++++++++ app/routes.tsx | 13 +++++++++++++ app/test-setup.ts | 27 ++++++++++++++++++++++++++ bun.lock | 3 +++ bunfig.toml | 1 + package.json | 1 + 9 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 app/layout/BottomNav.test.tsx create mode 100644 app/layout/BottomNav.tsx create mode 100644 app/layout/Shell.tsx create mode 100644 app/test-setup.ts diff --git a/app/App.tsx b/app/App.tsx index 75a4df74d..f4451915e 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,7 +1,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router' -import { ROUTES } from './routes' +import { ROUTER_ROUTES } from './routes' -const router = createBrowserRouter(ROUTES) +const router = createBrowserRouter(ROUTER_ROUTES) export function App() { return diff --git a/app/layout/BottomNav.test.tsx b/app/layout/BottomNav.test.tsx new file mode 100644 index 000000000..bda8d1417 --- /dev/null +++ b/app/layout/BottomNav.test.tsx @@ -0,0 +1,36 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { render } 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, bound to its own +// container, 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) + +test('shows the four primary destinations in order', () => { + const { getAllByRole } = render( + + + , + ) + const labels = getAllByRole('link').map((link) => link.textContent) + expect(labels).toEqual(['Map', 'Filters', 'Alerts', 'Me']) +}) + +test('marks the active destination for assistive tech', () => { + const { getByRole } = render( + + + , + ) + const active = 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/routes.tsx b/app/routes.tsx index f1f9d1c58..ea495850b 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -1,4 +1,5 @@ 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 @@ -53,3 +54,15 @@ export const ROUTES: RouteObject[] = [ }), }, ] + +/* + * 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/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 f2ad170e3..87b23a29f 100644 --- a/bun.lock +++ b/bun.lock @@ -88,6 +88,7 @@ "@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", @@ -449,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=="], diff --git a/bunfig.toml b/bunfig.toml index e289870c7..b91bb9ffd 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,3 +4,4 @@ exact = true [test] root = "." coverage = false +preload = ["./app/test-setup.ts"] diff --git a/package.json b/package.json index 59e533b1f..a5ec3bab4 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "@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", From 00c7e84a94298069caa0bd6ad28422bd4df6cc60 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:04:14 -0400 Subject: [PATCH 04/17] chore: drop the dead test preload app/test-setup.ts deliberately does not register happy-dom at import time, because a preload applies to the whole `bun test` run and there is no per-directory scoping, which broke three unrelated suites when tried. The file only exports setup and teardown functions now, and the one test that needs a DOM imports them and calls them from its own beforeAll. That leaves the preload entry doing nothing. Removing it keeps the suite at 80 passing, and it stops the config implying a global registration that is not happening, which would mislead anyone who later added a side effect to that file. Co-Authored-By: Claude Opus 5 --- bunfig.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/bunfig.toml b/bunfig.toml index b91bb9ffd..e289870c7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,4 +4,3 @@ exact = true [test] root = "." coverage = false -preload = ["./app/test-setup.ts"] From 8dc77fffd6085436d59ff70ddfb8d9f9e4aa9b83 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:05:49 -0400 Subject: [PATCH 05/17] feat(app): bootstrap the session from the settings endpoint --- app/session/types.ts | 9 ++++++++ app/session/useSession.test.ts | 26 +++++++++++++++++++++ app/session/useSession.ts | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 app/session/types.ts create mode 100644 app/session/useSession.test.ts create mode 100644 app/session/useSession.ts 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 } +} From 8448c21ca4e75e15ed9ff528e20e66ca7c525f8a Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:09:27 -0400 Subject: [PATCH 06/17] feat(app): build the hub at the root route --- app/pages/Hub.test.tsx | 18 ++++++++++++++++++ app/pages/Hub.tsx | 21 ++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/pages/Hub.test.tsx diff --git a/app/pages/Hub.test.tsx b/app/pages/Hub.test.tsx new file mode 100644 index 000000000..acd1fefac --- /dev/null +++ b/app/pages/Hub.test.tsx @@ -0,0 +1,18 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { setupDom, teardownDom } from '../test-setup' +import { Hub } from './Hub' + +beforeAll(setupDom) +afterAll(teardownDom) + +test('links to the four primary surfaces without a session', () => { + const { getAllByRole } = render( + + + , + ) + const hrefs = 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 index e848b4bfe..90b6ee890 100644 --- a/app/pages/Hub.tsx +++ b/app/pages/Hub.tsx @@ -1,8 +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 (

Hub

-

The hub arrives in a later plan.

+
) } From ca3b6c9cd6968f384f17fe489349801284d3897d Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:09:51 -0400 Subject: [PATCH 07/17] feat(app): render the profile page from the session payload --- app/pages/Profile.test.tsx | 60 ++++++++++++++++++++++++++++++++++++++ app/pages/Profile.tsx | 34 +++++++++++++++++++-- 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 app/pages/Profile.test.tsx diff --git a/app/pages/Profile.test.tsx b/app/pages/Profile.test.tsx new file mode 100644 index 000000000..ce8829079 --- /dev/null +++ b/app/pages/Profile.test.tsx @@ -0,0 +1,60 @@ +import { afterAll, afterEach, beforeAll, expect, mock, test } from 'bun:test' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { setupDom, teardownDom } from '../test-setup' +import { Profile } from './Profile' + +beforeAll(setupDom) +afterAll(teardownDom) + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +test('renders a loading affordance while the session resolves', () => { + globalThis.fetch = mock( + () => new Promise(() => {}), + ) as unknown as typeof fetch + const { getByText } = render( + + + , + ) + expect(getByText('Loading...')).toBeTruthy() +}) + +test('prompts to sign in when logged out, without account details', async () => { + globalThis.fetch = mock( + async () => + new Response(JSON.stringify({ user: { loggedIn: false, perms: {} } }), { + status: 200, + }), + ) as unknown as typeof fetch + const { findByText, queryByText } = render( + + + , + ) + expect(await findByText('Sign in to see your profile.')).toBeTruthy() + expect(queryByText('Loading...')).toBeNull() +}) + +test('renders the username when logged in', async () => { + globalThis.fetch = mock( + async () => + new Response( + JSON.stringify({ + user: { loggedIn: true, username: 'ash', perms: { map: true } }, + }), + { status: 200 }, + ), + ) as unknown as typeof fetch + const { findByText } = render( + + + , + ) + expect(await findByText('ash')).toBeTruthy() +}) diff --git a/app/pages/Profile.tsx b/app/pages/Profile.tsx index 7d43d2960..c63936561 100644 --- a/app/pages/Profile.tsx +++ b/app/pages/Profile.tsx @@ -1,9 +1,39 @@ +import { useSession } from '../session/useSession' + export function Profile() { + const { status, data } = useSession() + + if (status === 'loading') { + return ( +
+

Profile

+

Loading...

+
+ ) + } + + if (status === 'error' || !data?.user.loggedIn) { + return ( +
+

Profile

+

Sign in to see your profile.

+
+ ) + } + + const { username, perms } = data.user + return (

Profile

-

- The profile arrives in a later plan. +

{username}

+
    + {Object.keys(perms).map((perm) => ( +
  • {perm}
  • + ))} +
+

+ Account reset and linked accounts arrive in a later plan.

) From 0cf697d8480cd2acc0bb197ab3290fdac3ed44f7 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:10:30 -0400 Subject: [PATCH 08/17] fix(app): clean up profile render between test cases --- app/pages/Profile.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/pages/Profile.test.tsx b/app/pages/Profile.test.tsx index ce8829079..774532469 100644 --- a/app/pages/Profile.test.tsx +++ b/app/pages/Profile.test.tsx @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from 'bun:test' -import { render } from '@testing-library/react' +import { cleanup, render } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { setupDom, teardownDom } from '../test-setup' import { Profile } from './Profile' @@ -11,6 +11,10 @@ const originalFetch = globalThis.fetch afterEach(() => { globalThis.fetch = originalFetch + // `render()` queries are bound to `document.body`, not to the returned + // container, so a prior test's markup is still visible to the next + // test's queries unless the render tree is unmounted here. + cleanup() }) test('renders a loading affordance while the session resolves', () => { From 907f1ab27b5ffd238151f1506b9ed648759da865 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:19:46 -0400 Subject: [PATCH 09/17] feat(server): serve the 2.0 shell to flagged users Adds a boolean useAppShell column to the users table, defaulting to false so every existing row keeps the 1.0 client, and teaches the client router to pick between dist/app.html and dist/index.html per request. The route list is now the union of both clients' paths, so 1.0 deep links keep working and the flag, not the path, decides which shell a visitor receives. --- ...800_add_shell_preference_to_user_table.cjs | 23 ++++ server/src/routes/clientRouter.js | 83 +++++++++++++-- server/test/clientRouter.test.js | 100 ++++++++++++++++++ 3 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 server/src/db/migrations/20260824021800_add_shell_preference_to_user_table.cjs create mode 100644 server/test/clientRouter.test.js 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..971c218bd --- /dev/null +++ b/server/src/db/migrations/20260824021800_add_shell_preference_to_user_table.cjs @@ -0,0 +1,23 @@ +const config = require('@rm/config') + +/** + * @param {import("knex").Knex} knex + */ +exports.up = async (knex) => + knex.schema.table( + config.getSafe('database.settings.userTableName'), + (table) => { + table.boolean('useAppShell').notNullable().defaultTo(false) + }, + ) + +/** + * @param {import("knex").Knex} knex + */ +exports.down = async (knex) => + knex.schema.table( + config.getSafe('database.settings.userTableName'), + (table) => { + table.dropColumn('useAppShell') + }, + ) diff --git a/server/src/routes/clientRouter.js b/server/src/routes/clientRouter.js index 0b31d3fb4..5b0cdd03d 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,62 @@ 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])] + +/** + * 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 the shell file, honouring the NODE_CONFIG_ENV suffix on the + * dist directory that a multi instance install relies on. + * + * @param {{ user?: Record }} [req] + * @returns {string} + */ +function resolveShellPath(req) { + const suffix = process.env.NODE_CONFIG_ENV + ? `-${process.env.NODE_CONFIG_ENV}` + : '' + return path.join(__dirname, `../../../dist${suffix}`, resolveShell(req)) +} + +clientRouter.get(CLIENT_ROUTES, (req, res) => { + res.sendFile(resolveShellPath(req)) }) -module.exports = { clientRouter } +module.exports = { + clientRouter, + CLIENT_ROUTES, + LEGACY_ROUTES, + MODERN_ROUTES, + LEGACY_SHELL, + MODERN_SHELL, + SHELL_FLAG_COLUMN, + resolveShell, + resolveShellPath, +} diff --git a/server/test/clientRouter.test.js b/server/test/clientRouter.test.js new file mode 100644 index 000000000..d2a02134e --- /dev/null +++ b/server/test/clientRouter.test.js @@ -0,0 +1,100 @@ +const path = require('path') +const { afterEach, expect, test } = require('bun:test') + +const { + CLIENT_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) +}) From 3c39046621a92e701295946597a5f32d82f28504 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:22:05 -0400 Subject: [PATCH 10/17] refactor(server): fix the users table name in the new migration The pre-2.0 migrations resolve this table through database.settings.userTableName so an install could rename it. 2.0 drops that, and the change was announced as unsupported long enough ago that designing the new migration around it would be carrying weight nobody is standing on. An install that did rename the table now fails at migrate time with a missing-table error rather than quietly upgrading half way, which is the better of the two failures. The existing migrations keep reading the config value. They describe work already applied, and rewriting them would change the account of what ran. Removing the option from the config and the models is its own job. Co-Authored-By: Claude Opus 5 --- ...800_add_shell_preference_to_user_table.cjs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) 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 index 971c218bd..6c60cabbb 100644 --- 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 @@ -1,23 +1,24 @@ -const config = require('@rm/config') +/* + * 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( - config.getSafe('database.settings.userTableName'), - (table) => { - table.boolean('useAppShell').notNullable().defaultTo(false) - }, - ) + knex.schema.table(USER_TABLE, (table) => { + table.boolean('useAppShell').notNullable().defaultTo(false) + }) /** * @param {import("knex").Knex} knex */ exports.down = async (knex) => - knex.schema.table( - config.getSafe('database.settings.userTableName'), - (table) => { - table.dropColumn('useAppShell') - }, - ) + knex.schema.table(USER_TABLE, (table) => { + table.dropColumn('useAppShell') + }) From f52518be8455ac7f0334ce737543d7d78c0b47f0 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:27:35 -0400 Subject: [PATCH 11/17] fix(auth): carry the shell preference through local login Discord and Telegram spread the whole user row onto the session user, so they picked up the new useAppShell column for free. Local login assigns fields one at a time and did not copy it, so flagging a local account onto the 2.0 shell silently did nothing and the account stayed on 1.0. That is the failure mode worth avoiding here: nothing errors, the column holds the value asked for, and only the one auth method quietly ignores it. Co-Authored-By: Claude Opus 5 --- server/src/services/LocalClient.js | 6 ++++++ 1 file changed, 6 insertions(+) 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' From 650096b79458018256986bd457f505b24e96c676 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:29:55 -0400 Subject: [PATCH 12/17] test(auth): pin useAppShell reaching the session user on every login path Guards against the local-login gap fixed in f52518be, where Discord and Telegram spread the whole row and picked up the new column for free while LocalClient copied fields one at a time and silently dropped it. Verified against the pre-fix LocalClient to confirm the local-login test actually fails without the copy line. Co-Authored-By: Claude Opus 5 --- server/test/shellFlag.test.js | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 server/test/shellFlag.test.js diff --git a/server/test/shellFlag.test.js b/server/test/shellFlag.test.js new file mode 100644 index 000000000..2971529e8 --- /dev/null +++ b/server/test/shellFlag.test.js @@ -0,0 +1,55 @@ +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. Fixed in f52518be by adding the missing assignment. + * + * 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). Standing that config up is + * significant scaffolding for one field, so this pins the same invariant + * structurally instead, against the actual source rather than a duplicated + * behavior assumption: LocalClient must still contain the copy line, and + * Discord/Telegram must still build the session user by spreading the row + * rather than switching to the same manual, easy-to-forget field-by-field + * style. + */ + +const localSource = readFileSync( + require.resolve('../src/services/LocalClient'), + 'utf8', +) +const discordSource = readFileSync( + require.resolve('../src/services/DiscordClient'), + 'utf8', +) +const telegramSource = readFileSync( + require.resolve('../src/services/TelegramClient'), + 'utf8', +) + +test('local login explicitly copies the shell flag onto the session user', () => { + const copiesFlag = new RegExp( + `user\\.${SHELL_FLAG_COLUMN}\\s*=\\s*userExists\\.${SHELL_FLAG_COLUMN}\\b`, + ) + assert.match(localSource, copiesFlag) +}) + +test('Discord and Telegram build the session user by spreading the row, not by field copy', () => { + // A row spread picks up any column, present or future, without a matching + // line for each one. If either client ever moves to LocalClient's + // per-field style, this stops being true for free and the flag needs its + // own explicit copy there too, same as it now has in LocalClient. + assert.match(discordSource, /\.\.\.\s*userExists\b/) + assert.match(telegramSource, /\.\.\.\s*userExists\b/) +}) From 61795503655ad017436242fb106b92335df4e086 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:32:06 -0400 Subject: [PATCH 13/17] test(auth): assert every login path carries the shell flag Guards the gap that let a local account be flagged onto the 2.0 shell and silently stay on 1.0. Discord and Telegram spread the users-table row onto the session user, so a new column reaches them for free; local login copies fields one at a time and had to be told about this one. Driving the login end to end would mean standing up app config that only exists after a real server boot, which is a lot of scaffolding for one field assignment, so the invariant is pinned against the source instead. It is written as "spreads the row or copies the flag", not "keeps using a spread". Rewriting a client to assign fields explicitly is fine as long as the flag comes along, and a test that failed on correct code would get deleted the first time it was in the way. Co-Authored-By: Claude Opus 5 --- server/test/shellFlag.test.js | 77 ++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/server/test/shellFlag.test.js b/server/test/shellFlag.test.js index 2971529e8..6376d5c49 100644 --- a/server/test/shellFlag.test.js +++ b/server/test/shellFlag.test.js @@ -7,49 +7,50 @@ 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. Fixed in f52518be by adding the missing assignment. + * 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). Standing that config up is - * significant scaffolding for one field, so this pins the same invariant - * structurally instead, against the actual source rather than a duplicated - * behavior assumption: LocalClient must still contain the copy line, and - * Discord/Telegram must still build the session user by spreading the row - * rather than switching to the same manual, easy-to-forget field-by-field - * style. + * 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. */ -const localSource = readFileSync( - require.resolve('../src/services/LocalClient'), - 'utf8', -) -const discordSource = readFileSync( - require.resolve('../src/services/DiscordClient'), - 'utf8', -) -const telegramSource = readFileSync( - require.resolve('../src/services/TelegramClient'), - 'utf8', +/** @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`, ) -test('local login explicitly copies the shell flag onto the session user', () => { - const copiesFlag = new RegExp( - `user\\.${SHELL_FLAG_COLUMN}\\s*=\\s*userExists\\.${SHELL_FLAG_COLUMN}\\b`, - ) - assert.match(localSource, copiesFlag) +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('Discord and Telegram build the session user by spreading the row, not by field copy', () => { - // A row spread picks up any column, present or future, without a matching - // line for each one. If either client ever moves to LocalClient's - // per-field style, this stops being true for free and the flag needs its - // own explicit copy there too, same as it now has in LocalClient. - assert.match(discordSource, /\.\.\.\s*userExists\b/) - assert.match(telegramSource, /\.\.\.\s*userExists\b/) +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) }) From a5b1499ff1b574d492988104db605c3bc3983c4d Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:44:51 -0400 Subject: [PATCH 14/17] fix(server): keep 1.0 only paths on the 1.0 shell The handler was registered over the union of both route tables while the shell decision looked only at the user, so a flagged account asking for one of the 13 paths the 2.0 client has no route for got app.html and fell through to its catch-all NotFound. Shared map deep links, the blocked page and password reset were all among them. Register the 1.0 only paths, derived from the two tables so a third list cannot drift, with a handler that always sends the 1.0 shell, and let the flag decide only on paths both clients implement. The existing tests checked list membership, which is why this survived review. The new ones drive the router over HTTP and assert the shell each path returns under both flag states. --- server/src/routes/clientRouter.js | 40 ++++++++++++++--- server/test/clientRouter.test.js | 74 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/server/src/routes/clientRouter.js b/server/src/routes/clientRouter.js index 5b0cdd03d..a31cf7296 100644 --- a/server/src/routes/clientRouter.js +++ b/server/src/routes/clientRouter.js @@ -54,6 +54,15 @@ const MODERN_ROUTES = [ 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. * @@ -68,20 +77,37 @@ function resolveShell(req) { } /** - * Absolute path of the shell file, honouring the NODE_CONFIG_ENV suffix on the - * dist directory that a multi instance install relies on. + * 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 {{ user?: Record }} [req] + * @param {string} shell * @returns {string} */ -function resolveShellPath(req) { +function shellPath(shell) { const suffix = process.env.NODE_CONFIG_ENV ? `-${process.env.NODE_CONFIG_ENV}` : '' - return path.join(__dirname, `../../../dist${suffix}`, resolveShell(req)) + return path.join(__dirname, `../../../dist${suffix}`, shell) } -clientRouter.get(CLIENT_ROUTES, (req, res) => { +/** + * 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)) }) @@ -89,10 +115,12 @@ 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/test/clientRouter.test.js b/server/test/clientRouter.test.js index d2a02134e..42c26482c 100644 --- a/server/test/clientRouter.test.js +++ b/server/test/clientRouter.test.js @@ -1,8 +1,13 @@ +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, @@ -98,3 +103,72 @@ test('the 2.0 route table is served too, with no duplicates', () => { 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) + } + }) +}) From 2c00a3ef07c7d4ff213c09a84e7221d0845c234e Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:45:27 -0400 Subject: [PATCH 15/17] fix(server): let a bare / fall through to the router serve-static answers a directory request with index.html by default, and it is mounted ahead of the router, so a cold load of / was served the 1.0 shell off disk and never reached the code that picks a shell per user. Navigating to / inside the 2.0 client worked, since that is client side routing, which is why this went unnoticed. Passing index: false hands / to the router that already owns it. Hashed assets still come off disk, which the new test checks alongside the fall through. --- server/src/index.js | 4 +- server/test/staticFallthrough.test.js | 87 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 server/test/staticFallthrough.test.js 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/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/) +}) From 96eab26fad19d5030c72eed76eb20141dde58ee5 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:47:21 -0400 Subject: [PATCH 16/17] fix(server): refresh the shell flag without a logout Passport serializes the whole users row into the session and deserializes it without re-reading the database, so flipping the column did nothing until the person logged out. Sessions live in the database, so a restart did not help either, and someone hurt by a bad flag could not be rescued by setting it back. The settings endpoint already re-reads the row, so the refresh happens there. It patches the object stored in the session as well as req.user, because the deserializer hands back a copy and a req.user patch is discarded when the request ends. The flag then applies on the next page load, since the shell is chosen when the HTML is served and the settings call happens after it. The new test drives the real serializer pair over an express session, and fails if only req.user is patched. --- server/src/routes/rootRouter.js | 5 +- server/src/utils/refreshSessionUser.js | 46 +++++++++++++ server/test/shellFlagRefresh.test.js | 95 ++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 server/src/utils/refreshSessionUser.js create mode 100644 server/test/shellFlagRefresh.test.js 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/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/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\)/) +}) From 2ca22d9d8090c08afeac410048a6701367c75432 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:03:10 -0400 Subject: [PATCH 17/17] fix(app): stop component tests colliding in a shared document CI failed on "marks the active destination for assistive tech" with TestingLibraryElementError: Found multiple elements with the role "link" and name "Filters". It passed locally every time. Renders are appended to the same document and stay there, and the queries render returns are bound to the whole body rather than to the container that render created. The hub renders a link labelled Filters as well, so once both files had rendered there were two matches and getByRole threw for ambiguity. Whether that happened came down to which files had already run, which is why one machine saw it and the other did not. Both files now clean up after each test, and queries are scoped with within() to the container the render owns, so a stale render elsewhere cannot make them ambiguous. Verified against a deliberately polluted document holding two competing Filters links: the scoped query still resolves. Co-Authored-By: Claude Opus 5 --- app/layout/BottomNav.test.tsx | 29 ++++++++++++++++++++--------- app/pages/Hub.test.tsx | 16 ++++++++++++---- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/layout/BottomNav.test.tsx b/app/layout/BottomNav.test.tsx index bda8d1417..c93d33d07 100644 --- a/app/layout/BottomNav.test.tsx +++ b/app/layout/BottomNav.test.tsx @@ -1,5 +1,5 @@ -import { afterAll, beforeAll, expect, test } from 'bun:test' -import { render } from '@testing-library/react' +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' @@ -9,28 +9,39 @@ import { BottomNav } from './BottomNav' // 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, bound to its own -// container, needs the DOM only once the test body actually runs, so -// registering it here in beforeAll, scoped to this file, is enough. +// 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 { getAllByRole } = render( + const { container } = render( , ) - const labels = getAllByRole('link').map((link) => link.textContent) + 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 { getByRole } = render( + const { container } = render( , ) - const active = getByRole('link', { name: 'Filters' }) + const active = within(container).getByRole('link', { name: 'Filters' }) expect(active.getAttribute('aria-current')).toBe('page') }) diff --git a/app/pages/Hub.test.tsx b/app/pages/Hub.test.tsx index acd1fefac..36b45e127 100644 --- a/app/pages/Hub.test.tsx +++ b/app/pages/Hub.test.tsx @@ -1,5 +1,5 @@ -import { afterAll, beforeAll, expect, test } from 'bun:test' -import { render } from '@testing-library/react' +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' @@ -7,12 +7,20 @@ 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 { getAllByRole } = render( + const { container } = render( , ) - const hrefs = getAllByRole('link').map((link) => link.getAttribute('href')) + const hrefs = within(container) + .getAllByRole('link') + .map((link) => link.getAttribute('href')) expect(hrefs).toEqual(['/map', '/filters', '/alerts', '/profile']) })