WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

fix(web): address PR review — sanitize tokens, split try blocks, add logs, rkey validation (ATB-53)

- Change `import { WebAppEnv }` to `import type` in routes/index.ts (type-only import)
- Freeze FALLBACK_THEME and its fontUrls array to prevent mutation across callers
- Split single giant try block in resolveTheme into 6 focused blocks (policy fetch, policy parse, URI/rkey extraction, theme fetch, theme parse, CID check) with per-operation error messages
- Add rkey validation against /^[a-z0-9]+$/i before using in fetch URL (path traversal prevention)
- Log warning when theme URI is absent from availableThemes (CID check bypassed)
- Log warn with status+url on non-ok policy/theme responses instead of silent fallback
- SyntaxError from Response.json() is now caught as a data error and not re-thrown
- Fix detectColorScheme cookie regex to use (?:^|;\s*) prefix anchor (prevents x-atbb-color-scheme=dark from matching)
- Wrap :root token block in sanitizeCss() in base.tsx
- Filter fontUrls to https:// only before rendering link tags in base.tsx
- Add try-catch error boundary in createThemeMiddleware so unexpected throws use FALLBACK_THEME
- Add tests: invalid JSON in policy/theme responses, CID bypass warning, invalid rkey, cookie regex prefix fix, middleware error boundary, non-https font URL filtering

+252 -77
+16
apps/web/src/layouts/__tests__/base.test.tsx
··· 107 107 expect(html).not.toContain("fonts.googleapis.com"); 108 108 }); 109 109 110 + it("filters out non-https font URLs and does not render them", async () => { 111 + const themeWithUnsafeFontUrl = { 112 + ...FALLBACK_THEME, 113 + fontUrls: ["http://evil.com/style.css", "https://fonts.example.com/safe.css"], 114 + }; 115 + const unsafeFontApp = new Hono().get("/", (c) => 116 + c.html( 117 + <BaseLayout resolvedTheme={themeWithUnsafeFontUrl}>content</BaseLayout> 118 + ) 119 + ); 120 + const res = await unsafeFontApp.request("/"); 121 + const html = await res.text(); 122 + expect(html).not.toContain("http://evil.com/style.css"); 123 + expect(html).toContain("https://fonts.example.com/safe.css"); 124 + }); 125 + 110 126 it("does not render cssOverrides style tag when cssOverrides is null", async () => { 111 127 const themeNoOverrides = { ...FALLBACK_THEME, cssOverrides: null }; 112 128 const noOverridesApp = new Hono().get("/", (c) =>
+17 -14
apps/web/src/layouts/base.tsx
··· 43 43 <title>{props.title ?? "atBB Forum"}</title> 44 44 <style 45 45 dangerouslySetInnerHTML={{ 46 - __html: `:root { ${tokensToCss(resolvedTheme.tokens)} }`, 46 + __html: sanitizeCss(`:root { ${tokensToCss(resolvedTheme.tokens)} }`), 47 47 }} 48 48 /> 49 49 {resolvedTheme.cssOverrides && ( ··· 51 51 dangerouslySetInnerHTML={{ __html: sanitizeCss(resolvedTheme.cssOverrides) }} 52 52 /> 53 53 )} 54 - {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && ( 55 - <> 56 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 57 - <link 58 - rel="preconnect" 59 - href="https://fonts.gstatic.com" 60 - crossorigin="anonymous" 61 - /> 62 - {resolvedTheme.fontUrls.map((url) => ( 63 - <link rel="stylesheet" href={url} /> 64 - ))} 65 - </> 66 - )} 54 + {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && (() => { 55 + const safeFontUrls = resolvedTheme.fontUrls!.filter((url) => url.startsWith("https://")); 56 + return safeFontUrls.length > 0 ? ( 57 + <> 58 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 59 + <link 60 + rel="preconnect" 61 + href="https://fonts.gstatic.com" 62 + crossorigin="anonymous" 63 + /> 64 + {safeFontUrls.map((url) => ( 65 + <link rel="stylesheet" href={url} /> 66 + ))} 67 + </> 68 + ) : null; 69 + })()} 67 70 <link rel="stylesheet" href="/static/css/reset.css" /> 68 71 <link rel="stylesheet" href="/static/css/theme.css" /> 69 72 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
+83 -8
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 35 35 it("ignores unrecognized hint values and returns 'light'", () => { 36 36 expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 37 37 }); 38 + 39 + it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => { 40 + // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched. 41 + // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted. 42 + expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light"); 43 + }); 38 44 }); 39 45 40 46 describe("parseRkeyFromUri", () => { ··· 80 86 81 87 describe("resolveTheme", () => { 82 88 const mockFetch = vi.fn(); 89 + const mockLogger = vi.mocked(logger); 83 90 const APPVIEW = "http://localhost:3001"; 84 91 85 92 beforeEach(() => { 86 93 vi.stubGlobal("fetch", mockFetch); 87 - vi.mocked(logger.warn).mockClear(); 88 - vi.mocked(logger.error).mockClear(); 94 + mockLogger.warn.mockClear(); 95 + mockLogger.error.mockClear(); 89 96 }); 90 97 91 98 afterEach(() => { ··· 131 138 const result = await resolveTheme(APPVIEW, undefined, undefined); 132 139 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 133 140 expect(result.colorScheme).toBe("light"); 141 + expect(mockLogger.warn).toHaveBeenCalledWith( 142 + expect.stringContaining("non-ok status"), 143 + expect.objectContaining({ operation: "resolveTheme", status: 404 }) 144 + ); 134 145 }); 135 146 136 147 it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { ··· 138 149 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 139 150 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 140 151 expect(result.colorScheme).toBe("dark"); 152 + expect(mockLogger.warn).toHaveBeenCalledWith( 153 + expect.stringContaining("non-ok status"), 154 + expect.any(Object) 155 + ); 141 156 }); 142 157 143 158 it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { ··· 156 171 expect(mockFetch).toHaveBeenCalledTimes(1); 157 172 }); 158 173 159 - it("returns FALLBACK_THEME when theme fetch fails", async () => { 174 + it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => { 160 175 mockFetch 161 176 .mockResolvedValueOnce(policyResponse()) 162 177 .mockResolvedValueOnce({ ok: false, status: 404 }); 163 178 const result = await resolveTheme(APPVIEW, undefined, undefined); 164 179 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 180 + expect(mockLogger.warn).toHaveBeenCalledWith( 181 + expect.stringContaining("non-ok status"), 182 + expect.objectContaining({ operation: "resolveTheme", status: 404 }) 183 + ); 165 184 }); 166 185 167 186 it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { ··· 210 229 const result = await resolveTheme(APPVIEW, undefined, undefined); 211 230 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 212 231 expect(logger.error).toHaveBeenCalledWith( 213 - expect.stringContaining("Theme resolution failed"), 232 + expect.stringContaining("Theme policy fetch failed"), 214 233 expect.objectContaining({ operation: "resolveTheme" }) 215 234 ); 216 235 }); 217 236 218 237 it("re-throws programming errors (TypeError) rather than swallowing them", async () => { 219 - // A TypeError from a bug in the code should propagate, not be silently logged 220 - mockFetch.mockResolvedValueOnce({ 221 - ok: true, 222 - json: () => { throw new TypeError("Cannot read properties of null"); }, 238 + // A TypeError from a bug in the code should propagate, not be silently logged. 239 + // This TypeError comes from the fetch() mock itself (not from .json()), so it 240 + // is caught by the policy-fetch try block and re-thrown as a programming error. 241 + mockFetch.mockImplementationOnce(() => { 242 + throw new TypeError("Cannot read properties of null"); 223 243 }); 224 244 await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError); 225 245 }); ··· 241 261 const result = await resolveTheme(APPVIEW, undefined, undefined); 242 262 expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 243 263 expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 264 + }); 265 + 266 + it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => { 267 + mockFetch.mockResolvedValueOnce({ 268 + ok: true, 269 + json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 270 + }); 271 + const result = await resolveTheme(APPVIEW, undefined, undefined); 272 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 273 + expect(mockLogger.error).toHaveBeenCalledWith( 274 + expect.stringContaining("invalid JSON"), 275 + expect.objectContaining({ operation: "resolveTheme" }) 276 + ); 277 + }); 278 + 279 + it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => { 280 + mockFetch 281 + .mockResolvedValueOnce(policyResponse()) 282 + .mockResolvedValueOnce({ 283 + ok: true, 284 + json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 285 + }); 286 + const result = await resolveTheme(APPVIEW, undefined, undefined); 287 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 288 + expect(mockLogger.error).toHaveBeenCalledWith( 289 + expect.stringContaining("invalid JSON"), 290 + expect.objectContaining({ operation: "resolveTheme" }) 291 + ); 292 + }); 293 + 294 + it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => { 295 + mockFetch 296 + .mockResolvedValueOnce(policyResponse({ availableThemes: [] })) 297 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 298 + await resolveTheme(APPVIEW, undefined, undefined); 299 + expect(mockLogger.warn).toHaveBeenCalledWith( 300 + expect.stringContaining("not in availableThemes"), 301 + expect.objectContaining({ 302 + operation: "resolveTheme", 303 + themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 304 + }) 305 + ); 306 + }); 307 + 308 + it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 + // rkey extracted from URI would be "..%2Fsecret" after split — fails /^[a-z0-9]+$/i 310 + mockFetch.mockResolvedValueOnce( 311 + policyResponse({ 312 + defaultLightThemeUri: "at://did/col/../../secret", 313 + }) 314 + ); 315 + const result = await resolveTheme(APPVIEW, undefined, undefined); 316 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 317 + // Only the policy fetch should have been made (no theme fetch) 318 + expect(mockFetch).toHaveBeenCalledTimes(1); 244 319 }); 245 320 });
+88 -51
apps/web/src/lib/theme-resolution.ts
··· 15 15 }; 16 16 17 17 /** Hardcoded fallback used when theme policy is missing or resolution fails. */ 18 - export const FALLBACK_THEME: ResolvedTheme = { 18 + export const FALLBACK_THEME: ResolvedTheme = Object.freeze({ 19 19 tokens: neobrutalLight as Record<string, string>, 20 20 cssOverrides: null, 21 - fontUrls: [ 21 + fontUrls: Object.freeze([ 22 22 "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 23 - ], 23 + ]), 24 24 colorScheme: "light", 25 - }; 25 + } as const) as ResolvedTheme; 26 26 27 27 /** 28 28 * Detects the user's preferred color scheme. ··· 32 32 cookieHeader: string | undefined, 33 33 hint: string | undefined 34 34 ): "light" | "dark" { 35 - const match = cookieHeader?.match(/atbb-color-scheme=(light|dark)/); 35 + const match = cookieHeader?.match(/(?:^|;\s*)atbb-color-scheme=(light|dark)/); 36 36 if (match) return match[1] as "light" | "dark"; 37 37 if (hint === "dark") return "dark"; 38 38 return "light"; ··· 79 79 colorSchemeHint: string | undefined 80 80 ): Promise<ResolvedTheme> { 81 81 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 82 + // TODO: user preference (Theme Phase 4) 82 83 83 - // Step 1: User preference 84 - // TODO: implement when space.atbb.membership.preferredTheme is added (Theme Phase 4) 85 - 86 - // Steps 2-3: Forum default via theme policy 84 + // ── Step 1: Fetch theme policy ───────────────────────────────────────────── 85 + let policyRes: Response; 87 86 try { 88 - const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 87 + policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 89 88 if (!policyRes.ok) { 89 + logger.warn("Theme policy fetch returned non-ok status — using fallback", { 90 + operation: "resolveTheme", 91 + status: policyRes.status, 92 + url: `${appviewUrl}/api/theme-policy`, 93 + }); 90 94 return { ...FALLBACK_THEME, colorScheme }; 91 95 } 96 + } catch (error) { 97 + if (isProgrammingError(error)) throw error; 98 + logger.error("Theme policy fetch failed — using fallback", { 99 + operation: "resolveTheme", 100 + error: error instanceof Error ? error.message : String(error), 101 + }); 102 + return { ...FALLBACK_THEME, colorScheme }; 103 + } 92 104 93 - const policy = (await policyRes.json()) as ThemePolicyResponse; 105 + // ── Step 2: Parse policy JSON ────────────────────────────────────────────── 106 + let policy: ThemePolicyResponse; 107 + try { 108 + policy = (await policyRes.json()) as ThemePolicyResponse; 109 + } catch { 110 + // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 111 + logger.error("Theme policy response contained invalid JSON — using fallback", { 112 + operation: "resolveTheme", 113 + url: `${appviewUrl}/api/theme-policy`, 114 + }); 115 + return { ...FALLBACK_THEME, colorScheme }; 116 + } 94 117 95 - const defaultUri = 96 - colorScheme === "dark" 97 - ? policy.defaultDarkThemeUri 98 - : policy.defaultLightThemeUri; 118 + // ── Step 3: Extract default theme URI and rkey ───────────────────────────── 119 + const defaultUri = 120 + colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 121 + if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 99 122 100 - if (!defaultUri) { 101 - return { ...FALLBACK_THEME, colorScheme }; 102 - } 123 + const rkey = parseRkeyFromUri(defaultUri); 124 + if (!rkey || !/^[a-z0-9]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 103 125 104 - const rkey = parseRkeyFromUri(defaultUri); 105 - if (!rkey) { 106 - return { ...FALLBACK_THEME, colorScheme }; 107 - } 126 + const expectedCid = 127 + policy.availableThemes.find((t: { uri: string; cid: string }) => t.uri === defaultUri)?.cid ?? null; 128 + if (expectedCid === null) { 129 + logger.warn("Theme URI not in availableThemes — skipping CID check", { 130 + operation: "resolveTheme", 131 + themeUri: defaultUri, 132 + }); 133 + } 108 134 109 - // If the URI is absent from availableThemes (data inconsistency in AppView), 110 - // expectedCid will be null and the CID check is skipped — the theme is served 111 - // without integrity verification rather than falling back. 112 - const expectedCid = 113 - policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 114 - 115 - const themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 135 + // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 136 + let themeRes: Response; 137 + try { 138 + themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 116 139 if (!themeRes.ok) { 117 - return { ...FALLBACK_THEME, colorScheme }; 118 - } 119 - 120 - const theme = (await themeRes.json()) as ThemeResponse; 121 - 122 - if (expectedCid && theme.cid !== expectedCid) { 123 - logger.warn("Theme CID mismatch — using hardcoded fallback", { 140 + logger.warn("Theme fetch returned non-ok status — using fallback", { 124 141 operation: "resolveTheme", 125 - expectedCid, 126 - actualCid: theme.cid, 142 + status: themeRes.status, 143 + rkey, 127 144 themeUri: defaultUri, 128 145 }); 129 146 return { ...FALLBACK_THEME, colorScheme }; 130 147 } 131 - 132 - return { 133 - // AppView stores tokens as jsonb — trusting that all values are strings 134 - // as enforced by the theme editor UI. Non-string values would render as 135 - // their string coercion in CSS, which is benign but unexpected. 136 - tokens: theme.tokens as Record<string, string>, 137 - cssOverrides: theme.cssOverrides ?? null, 138 - fontUrls: theme.fontUrls ?? null, 139 - colorScheme, 140 - }; 141 148 } catch (error) { 142 - // Re-throw programming errors (bugs) — they should surface, not be hidden. 143 - // Only network/data errors should fall back silently. 144 149 if (isProgrammingError(error)) throw error; 145 - logger.error("Theme resolution failed — using hardcoded fallback", { 150 + logger.error("Theme fetch failed — using fallback", { 146 151 operation: "resolveTheme", 152 + rkey, 147 153 error: error instanceof Error ? error.message : String(error), 148 154 }); 149 155 return { ...FALLBACK_THEME, colorScheme }; 150 156 } 157 + 158 + // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 159 + let theme: ThemeResponse; 160 + try { 161 + theme = (await themeRes.json()) as ThemeResponse; 162 + } catch { 163 + logger.error("Theme response contained invalid JSON — using fallback", { 164 + operation: "resolveTheme", 165 + rkey, 166 + themeUri: defaultUri, 167 + }); 168 + return { ...FALLBACK_THEME, colorScheme }; 169 + } 170 + 171 + // ── Step 6: CID integrity check ──────────────────────────────────────────── 172 + if (expectedCid && theme.cid !== expectedCid) { 173 + logger.warn("Theme CID mismatch — using hardcoded fallback", { 174 + operation: "resolveTheme", 175 + expectedCid, 176 + actualCid: theme.cid, 177 + themeUri: defaultUri, 178 + }); 179 + return { ...FALLBACK_THEME, colorScheme }; 180 + } 181 + 182 + return { 183 + tokens: theme.tokens as Record<string, string>, 184 + cssOverrides: theme.cssOverrides ?? null, 185 + fontUrls: theme.fontUrls ?? null, 186 + colorScheme, 187 + }; 151 188 }
+35 -1
apps/web/src/middleware/__tests__/theme.test.ts
··· 5 5 6 6 vi.mock("../../lib/theme-resolution.js", () => ({ 7 7 resolveTheme: vi.fn(), 8 + FALLBACK_THEME: { 9 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 10 + cssOverrides: null, 11 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"], 12 + colorScheme: "light", 13 + }, 8 14 })); 9 15 10 - import { resolveTheme } from "../../lib/theme-resolution.js"; 16 + vi.mock("../../lib/logger.js", () => ({ 17 + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, 18 + })); 19 + 20 + import { resolveTheme, FALLBACK_THEME } from "../../lib/theme-resolution.js"; 21 + import { logger } from "../../lib/logger.js"; 11 22 12 23 const mockResolveTheme = vi.mocked(resolveTheme); 24 + const mockLogger = vi.mocked(logger); 13 25 14 26 const MOCK_THEME: ResolvedTheme = { 15 27 tokens: { "color-bg": "#ffffff", "color-primary": "#ff5c00" }, ··· 22 34 beforeEach(() => { 23 35 mockResolveTheme.mockReset(); 24 36 mockResolveTheme.mockResolvedValue(MOCK_THEME); 37 + mockLogger.error.mockClear(); 25 38 }); 26 39 27 40 it("stores resolved theme in context for downstream handlers", async () => { ··· 108 121 expect(res.status).toBe(200); 109 122 const body = await res.json() as { message: string }; 110 123 expect(body.message).toBe("handler ran"); 124 + }); 125 + 126 + it("catches unexpected throws from resolveTheme, logs the error, and sets FALLBACK_THEME", async () => { 127 + mockResolveTheme.mockRejectedValueOnce(new TypeError("Unexpected programming error")); 128 + 129 + let capturedTheme: ResolvedTheme | undefined; 130 + const app = new Hono<WebAppEnv>() 131 + .use("*", createThemeMiddleware("http://appview.test")) 132 + .get("/test", (c) => { 133 + capturedTheme = c.get("theme"); 134 + return c.json({ ok: true }); 135 + }); 136 + 137 + // The request must NOT propagate the error — the middleware should catch it 138 + const res = await app.request("http://localhost/test"); 139 + expect(res.status).toBe(200); 140 + expect(capturedTheme).toEqual(FALLBACK_THEME); 141 + expect(mockLogger.error).toHaveBeenCalledWith( 142 + expect.stringContaining("resolveTheme threw unexpectedly"), 143 + expect.objectContaining({ operation: "createThemeMiddleware" }) 144 + ); 111 145 }); 112 146 });
+12 -2
apps/web/src/middleware/theme.ts
··· 1 1 import type { MiddlewareHandler } from "hono"; 2 2 import type { WebAppEnv } from "../lib/theme-resolution.js"; 3 - import { resolveTheme } from "../lib/theme-resolution.js"; 3 + import { resolveTheme, FALLBACK_THEME } from "../lib/theme-resolution.js"; 4 + import { logger } from "../lib/logger.js"; 4 5 5 6 export function createThemeMiddleware(appviewUrl: string): MiddlewareHandler<WebAppEnv> { 6 7 return async (c, next) => { 7 8 const cookieHeader = c.req.header("Cookie"); 8 9 const colorSchemeHint = c.req.header("Sec-CH-Prefers-Color-Scheme"); 9 - const theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint); 10 + let theme; 11 + try { 12 + theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint); 13 + } catch (error) { 14 + logger.error("createThemeMiddleware: resolveTheme threw unexpectedly — using fallback", { 15 + operation: "createThemeMiddleware", 16 + error: error instanceof Error ? error.message : String(error), 17 + }); 18 + theme = FALLBACK_THEME; 19 + } 10 20 c.set("theme", theme); 11 21 await next(); 12 22 };
+1 -1
apps/web/src/routes/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { loadConfig } from "../lib/config.js"; 3 - import { WebAppEnv } from "../lib/theme-resolution.js"; 3 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 4 4 import { createThemeMiddleware } from "../middleware/theme.js"; 5 5 import { createHomeRoutes } from "./home.js"; 6 6 import { createBoardsRoutes } from "./boards.js";