From f63dfde85d2d2821fdd574db78c63f00f235ffe3 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 18:27:35 -0700 Subject: [PATCH 1/8] fix(ui): defer the visual-viewport read out of render Reading window during render made the client's first render disagree with the SSR markup; start at zero and fill in after mount, matching Mantine's useViewportSize contract. --- src/features/core/hooks/use-visual-viewport-size.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/core/hooks/use-visual-viewport-size.ts b/src/features/core/hooks/use-visual-viewport-size.ts index 98584e6..dfc3a94 100644 --- a/src/features/core/hooks/use-visual-viewport-size.ts +++ b/src/features/core/hooks/use-visual-viewport-size.ts @@ -5,11 +5,13 @@ const eventListerOptions = { }; const useVisualViewportSize = () => { - const windowExists = typeof window !== 'undefined'; + // Starts at zero on server and client alike, filling in after mount — the + // same contract as Mantine's useViewportSize. Reading window during render + // makes the client's first render disagree with the SSR markup. const [windowSize, setWindowSize] = useState({ - width: windowExists ? window.visualViewport?.width || 0 : 0, - height: windowExists ? window.visualViewport?.height || 0 : 0, - top: windowExists ? window.visualViewport?.offsetTop || 0 : 0, + width: 0, + height: 0, + top: 0, }); const rafRef = useRef(null); From a45c2aeafae8763b5749b5041d0b37c8a592334d Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 18:27:40 -0700 Subject: [PATCH 2/8] fix(pwa): reword the install prompt and keep it off the bottom nav The banner sat fixed at the viewport bottom at z-index 1000, covering the authed shell's mobile nav; it now offsets above the nav's geometry when a signed-in mobile user has one. New casual copy replaces the Twilio line, the dismiss button gains a localized aria-label, and de/es/ja catalogs carry the new strings. --- src/components/ios-install-prompt.tsx | 20 +++++++++++++++++--- src/locales/de/messages.po | 16 ++++++++++------ src/locales/en/messages.po | 16 ++++++++++------ src/locales/es/messages.po | 16 ++++++++++------ src/locales/ja/messages.po | 16 ++++++++++------ 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx index a6aabd2..6b4a8f6 100644 --- a/src/components/ios-install-prompt.tsx +++ b/src/components/ios-install-prompt.tsx @@ -2,9 +2,16 @@ import { useEffect, useState } from 'react' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { Trans, useLingui } from '@lingui/react/macro' +import { useAuth } from '@/contexts/auth-context' +import { useIsMobile } from '@/hooks/use-is-mobile' + +// Navbar geometry: 4rem height + 0.5rem margin + its safe-area bottom offset. +const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' export function IOSInstallPrompt() { const { t } = useLingui() + const { user } = useAuth() + const isMobile = useIsMobile() const [show, setShow] = useState(false) const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) @@ -36,22 +43,29 @@ export function IOSInstallPrompt() { ? t`Tap Share → Add to Home Screen` : t`Tap Menu (⋮) → Add to Home screen` + // Navbar renders only under _authed, so a signed-in mobile user on a public + // route has no nav to clear. + const aboveBottomNav = Boolean(user) && isMobile + const bottomStyle = aboveBottomNav + ? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' } + : { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' } + return ( - + - Please install FLXN • This will save me Twilio credits as you won't be signed out! + Add FLXN to your home screen — smoother experience, always one tap away {instructions} - + diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po index 2c1f76f..3e39176 100644 --- a/src/locales/de/messages.po +++ b/src/locales/de/messages.po @@ -392,6 +392,10 @@ msgstr "Aktivitäten" msgid "Activity Details" msgstr "Aktivitätsdetails" +#: src/components/ios-install-prompt.tsx:61 +msgid "Add FLXN to your home screen — smoother experience, always one tap away" +msgstr "Leg FLXN auf deinen Homescreen — läuft flüssiger und ist immer nur einen Tipp entfernt" + #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" msgstr "Spieler hinzufügen" @@ -841,6 +845,10 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich" msgid "Disconnect Spotify" msgstr "Spotify trennen" +#: src/components/ios-install-prompt.tsx:68 +msgid "Dismiss" +msgstr "Schließen" + #: src/features/bracket/components/match-report.tsx:159 msgid "Does this look right?" msgstr "Sieht das richtig aus?" @@ -1864,10 +1872,6 @@ msgstr "Spieler sind erforderlich" msgid "Playing on {0}" msgstr "Wird auf {0} abgespielt" -#: src/components/ios-install-prompt.tsx:47 -msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!" -msgstr "Installier FLXN • Das spart mir Twilio-Credits, weil du nicht ausgeloggt wirst!" - #: src/features/tournaments/components/group-stage-view.tsx:314 msgid "Populate Knockout Bracket" msgstr "K.-o.-Runde befüllen" @@ -2320,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Tippe unten auf einen Gegner zum Vergleichen" -#: src/components/ios-install-prompt.tsx:37 +#: src/components/ios-install-prompt.tsx:44 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen" -#: src/components/ios-install-prompt.tsx:36 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Share → Add to Home Screen" msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen" diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index 65dbc22..c746078 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -392,6 +392,10 @@ msgstr "Activities" msgid "Activity Details" msgstr "Activity Details" +#: src/components/ios-install-prompt.tsx:61 +msgid "Add FLXN to your home screen — smoother experience, always one tap away" +msgstr "Add FLXN to your home screen — smoother experience, always one tap away" + #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" msgstr "Add Player" @@ -841,6 +845,10 @@ msgstr "deviceId is required for transfer action" msgid "Disconnect Spotify" msgstr "Disconnect Spotify" +#: src/components/ios-install-prompt.tsx:68 +msgid "Dismiss" +msgstr "Dismiss" + #: src/features/bracket/components/match-report.tsx:159 msgid "Does this look right?" msgstr "Does this look right?" @@ -1864,10 +1872,6 @@ msgstr "Players are required" msgid "Playing on {0}" msgstr "Playing on {0}" -#: src/components/ios-install-prompt.tsx:47 -msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!" -msgstr "Please install FLXN • This will save me Twilio credits as you won't be signed out!" - #: src/features/tournaments/components/group-stage-view.tsx:314 msgid "Populate Knockout Bracket" msgstr "Populate Knockout Bracket" @@ -2320,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Tap an opponent below to compare" -#: src/components/ios-install-prompt.tsx:37 +#: src/components/ios-install-prompt.tsx:44 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Tap Menu (⋮) → Add to Home screen" -#: src/components/ios-install-prompt.tsx:36 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Share → Add to Home Screen" msgstr "Tap Share → Add to Home Screen" diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po index c399db6..1a0c931 100644 --- a/src/locales/es/messages.po +++ b/src/locales/es/messages.po @@ -392,6 +392,10 @@ msgstr "Actividades" msgid "Activity Details" msgstr "Detalles de la actividad" +#: src/components/ios-install-prompt.tsx:61 +msgid "Add FLXN to your home screen — smoother experience, always one tap away" +msgstr "Agrega FLXN a tu pantalla de inicio — funciona mejor y siempre está a un toque" + #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" msgstr "Agregar Jugador" @@ -841,6 +845,10 @@ msgstr "Se requiere deviceId para la acción de transferencia" msgid "Disconnect Spotify" msgstr "Desconectar Spotify" +#: src/components/ios-install-prompt.tsx:68 +msgid "Dismiss" +msgstr "Cerrar" + #: src/features/bracket/components/match-report.tsx:159 msgid "Does this look right?" msgstr "¿Se ve bien así?" @@ -1864,10 +1872,6 @@ msgstr "Los jugadores son obligatorios" msgid "Playing on {0}" msgstr "Reproduciendo en {0}" -#: src/components/ios-install-prompt.tsx:47 -msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!" -msgstr "Por favor instala FLXN • Así me ahorras créditos de Twilio porque no se cerrará tu sesión" - #: src/features/tournaments/components/group-stage-view.tsx:314 msgid "Populate Knockout Bracket" msgstr "Llenar el bracket eliminatorio" @@ -2320,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Toca un oponente abajo para comparar" -#: src/components/ios-install-prompt.tsx:37 +#: src/components/ios-install-prompt.tsx:44 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio" -#: src/components/ios-install-prompt.tsx:36 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Share → Add to Home Screen" msgstr "Toca Compartir → Agregar a pantalla de inicio" diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po index 5b6e10a..2c73643 100644 --- a/src/locales/ja/messages.po +++ b/src/locales/ja/messages.po @@ -392,6 +392,10 @@ msgstr "アクティビティ" msgid "Activity Details" msgstr "アクティビティの詳細" +#: src/components/ios-install-prompt.tsx:61 +msgid "Add FLXN to your home screen — smoother experience, always one tap away" +msgstr "FLXNをホーム画面に追加。もっと快適に使えて、いつでもワンタップで開けます" + #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" msgstr "プレイヤーを追加" @@ -841,6 +845,10 @@ msgstr "転送操作にはdeviceIdが必要です" msgid "Disconnect Spotify" msgstr "Spotifyの連携を解除" +#: src/components/ios-install-prompt.tsx:68 +msgid "Dismiss" +msgstr "閉じる" + #: src/features/bracket/components/match-report.tsx:159 msgid "Does this look right?" msgstr "これで合っていますか?" @@ -1864,10 +1872,6 @@ msgstr "プレイヤーの選択が必要です" msgid "Playing on {0}" msgstr "{0}で再生中" -#: src/components/ios-install-prompt.tsx:47 -msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!" -msgstr "FLXNをインストールしてください • ログアウトされなくなるので、Twilioのクレジット節約になります!" - #: src/features/tournaments/components/group-stage-view.tsx:314 msgid "Populate Knockout Bracket" msgstr "ノックアウトブラケットを作成" @@ -2320,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "下の対戦相手をタップして比較" -#: src/components/ios-install-prompt.tsx:37 +#: src/components/ios-install-prompt.tsx:44 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」" -#: src/components/ios-install-prompt.tsx:36 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Share → Add to Home Screen" msgstr "共有をタップ →「ホーム画面に追加」" From 60e91d137138957ca486e4e4bdac3d419dc7308a Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 18:27:45 -0700 Subject: [PATCH 3/8] fix(pocketbase): self-heal admin auth instead of stranding requests beforeSend now skips its gate for the auth endpoints (no self-await deadlock), swallows a rejected authPromise and re-authenticates when the store is invalid, re-stamps Authorization after the gate so requests built mid-auth carry the fresh token, and guards startTokenRefresh against stacking intervals. A failed boot auth logs a diagnostic instead of crashing the process; recovery happens on the next request. --- src/lib/pocketbase/client.ts | 52 +++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts index fe548da..032bb5f 100644 --- a/src/lib/pocketbase/client.ts +++ b/src/lib/pocketbase/client.ts @@ -19,15 +19,36 @@ class PocketBaseAdminClient { this.pb = new PocketBase(process.env.POCKETBASE_URL); this.pb.beforeSend = async (url, options) => { - await this.authPromise; - - if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) { + // The auth requests themselves must skip the gate below: gating them on + // authPromise would make them await their own completion (deadlock). + if (!url.includes("/collections/_superusers/auth-")) { try { - await this.refreshAuth(); - } catch (error) { - console.error('Failed to refresh admin token, re-authenticating:', error); - await this.authenticate(); + await this.authPromise; + } catch { + // Swallow: fall through to self-heal so a rejected authPromise can't strand every request. } + + if (!this.pb.authStore.isValid) { + // Self-heal: re-auth once PocketBase is reachable again, no restart. + this.authPromise = this.authenticate(); + await this.authPromise; + this.startTokenRefresh(); + } else if (this.isTokenExpiringSoon()) { + try { + await this.refreshAuth(); + } catch (error) { + console.error('Failed to refresh admin token, re-authenticating:', error); + this.authPromise = this.authenticate(); + await this.authPromise; + } + } + + // The SDK stamps Authorization before this hook runs, so a request + // built while auth was still in flight carries no (or a stale) token. + options.headers = { + ...options.headers, + Authorization: this.pb.authStore.token, + }; } options.cache = "no-store"; @@ -54,9 +75,18 @@ class PocketBaseAdminClient { Object.assign(this, createPushService(this.pb)); this.authPromise = this.authenticate(); - this.authPromise.then(() => { - this.startTokenRefresh(); - }); + // Fail soft at boot: an unreachable PocketBase must not crash the process; + // beforeSend self-heals on the next request. + this.authPromise + .then(() => { + this.startTokenRefresh(); + }) + .catch(() => { + console.error( + "PocketBase admin authentication failed - is PocketBase running and are " + + "POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?" + ); + }); } private async authenticate() { @@ -98,6 +128,8 @@ class PocketBaseAdminClient { } private startTokenRefresh() { + if (this.refreshInterval) return; + this.refreshInterval = setInterval(async () => { try { await this.refreshAuth(); From 9fc79dfc077e7bdebe588c704e0661688cdc072d Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 18:27:53 -0700 Subject: [PATCH 4/8] fix(audit): readable names, single writer, denial rows, redacted args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity log recorded Start's hashed function id (a sha256 URL segment) as the name; the middleware now reads compile-time serverFnMeta.name with the path segment as fallback. toServerResult resolves { success:false } instead of throwing, so the middleware logged failures as successes while toServerResult wrote a duplicate row with its own dead name parser — the middleware is now the single writer and reads the envelope's success flag. Admin denials, which threw before the logging middleware ran, get their own audit row. Arguments are redacted (phone/otp/token keys) and truncated at 2KB. The admin activities search binds its filter parameters (likePattern escaping) instead of interpolating raw input. Adds vitest with node-env tests for the middleware, redaction, and filter utils, and extends logging coverage to mutating fns that lacked it. --- bun.lock | 53 +++- package.json | 4 +- src/features/badges/server.ts | 5 +- src/features/players/server.ts | 2 +- src/features/tournaments/server.ts | 2 +- src/lib/pocketbase/services/activities.ts | 12 +- src/lib/pocketbase/util/filter.test.ts | 118 +++++++++ src/lib/pocketbase/util/filter.ts | 28 +++ src/lib/pocketbase/util/like-pattern.test.ts | 69 ++++++ src/lib/pocketbase/util/like-pattern.ts | 10 + src/lib/redact.test.ts | 36 +++ src/lib/redact.ts | 21 ++ .../tanstack-query/utils/to-server-result.ts | 44 +--- src/test/pb-filter.ts | 14 ++ src/utils/activities.test.ts | 226 ++++++++++++++++++ src/utils/activities.ts | 92 ++++++- src/utils/supertokens.ts | 10 +- vitest.config.mjs | 12 + 18 files changed, 691 insertions(+), 67 deletions(-) create mode 100644 src/lib/pocketbase/util/filter.test.ts create mode 100644 src/lib/pocketbase/util/filter.ts create mode 100644 src/lib/pocketbase/util/like-pattern.test.ts create mode 100644 src/lib/pocketbase/util/like-pattern.ts create mode 100644 src/lib/redact.test.ts create mode 100644 src/lib/redact.ts create mode 100644 src/test/pb-filter.ts create mode 100644 src/utils/activities.test.ts create mode 100644 vitest.config.mjs diff --git a/bun.lock b/bun.lock index 231b5ea..50968ac 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.11", "workbox-build": "^7.4.1", }, }, @@ -499,6 +500,8 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="], @@ -631,6 +634,10 @@ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -665,6 +672,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -687,6 +708,8 @@ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -749,6 +772,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -855,6 +880,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -867,12 +894,14 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="], @@ -1189,6 +1218,8 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], @@ -1397,6 +1428,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], @@ -1413,6 +1446,10 @@ "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1457,10 +1494,16 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -1541,6 +1584,8 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], @@ -1561,6 +1606,8 @@ "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="], "workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="], @@ -1817,6 +1864,8 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], @@ -1907,6 +1956,8 @@ "unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], diff --git a/package.json b/package.json index 8b8ae3a..c14bbab 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "extract": "lingui extract --clean", "i18n:check": "lingui extract --clean && lingui compile --strict", "build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs", - "start": "bun run server.ts" + "start": "bun run server.ts", + "test": "vitest run" }, "dependencies": { "@hello-pangea/dnd": "^18.0.1", @@ -73,6 +74,7 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.11", "workbox-build": "^7.4.1" } } diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts index 1b9ad54..fbaaba0 100644 --- a/src/features/badges/server.ts +++ b/src/features/badges/server.ts @@ -1,5 +1,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; +import { serverFnLoggingMiddleware } from "@/utils/activities"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { z } from "zod"; @@ -13,7 +14,7 @@ export const getPlayerBadges = createServerFn() ); export const migrateBadgeProgress = createServerFn() - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async () => toServerResult(async () => { const result = await pbAdmin.migrateBadgeProgress(); @@ -37,7 +38,7 @@ export const awardManualBadge = createServerFn() playerId: z.string(), badgeId: z.string(), })) - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 6d7ea38..3893183 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -84,7 +84,7 @@ export const updatePlayer = createServerFn() export const createPlayer = createServerFn() .validator(playerInputSchema) - .middleware([superTokensFunctionMiddleware]) + .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ context, data }) => toServerResult(async () => { const userAuthId = context.userAuthId; diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts index e7f393b..72e4230 100644 --- a/src/features/tournaments/server.ts +++ b/src/features/tournaments/server.ts @@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn() tournamentId: z.string(), seed: z.number().optional() })) - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId); diff --git a/src/lib/pocketbase/services/activities.ts b/src/lib/pocketbase/services/activities.ts index c2a4434..a6d9bfb 100644 --- a/src/lib/pocketbase/services/activities.ts +++ b/src/lib/pocketbase/services/activities.ts @@ -1,5 +1,7 @@ import PocketBase from "pocketbase"; import { PlayerInfo } from "@/features/players/types"; +import { pbFilter } from "../util/filter"; +import { likePattern } from "../util/like-pattern"; export interface Activity { id: string; @@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) { const filters: string[] = []; if (name) { - filters.push(`name ~ "${name}"`); + filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) })); } if (player) { - filters.push(`player = "${player}"`); + filters.push(pbFilter(pb, "player = {:player}", { player })); } if (success !== undefined) { - filters.push(`success = ${success}`); + filters.push(pbFilter(pb, "success = {:success}", { success })); } const filterString = filters.length > 0 ? filters.join(" && ") : ""; @@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) { async getActivitiesByUser(userId: string, limit: number = 50): Promise { const result = await pb.collection("activities").getList(1, limit, { - filter: `player = "${userId}"`, + filter: pbFilter(pb, "player = {:userId}", { userId }), sort: "-created", }); return result.items; @@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) { async getActivitiesByFunction(functionName: string, limit: number = 50): Promise { const result = await pb.collection("activities").getList(1, limit, { - filter: `name = "${functionName}"`, + filter: pbFilter(pb, "name = {:functionName}", { functionName }), sort: "-created", }); return result.items; diff --git a/src/lib/pocketbase/util/filter.test.ts b/src/lib/pocketbase/util/filter.test.ts new file mode 100644 index 0000000..bce9efe --- /dev/null +++ b/src/lib/pocketbase/util/filter.test.ts @@ -0,0 +1,118 @@ +import PocketBase from "pocketbase"; +import { describe, expect, it } from "vitest"; +import { pbFilter } from "./filter"; + +// Real SDK, not a stub: the escaping under test lives in PocketBase's own +// replaceAll. +const pb = new PocketBase("http://pocketbase.test"); + +const quotedLiteral = (expression: string) => { + const open = expression.indexOf("'"); + let literal = ""; + for (let i = open + 1; i < expression.length; i++) { + if (expression[i] === "\\") { + literal += expression[i + 1]; + i++; + continue; + } + if (expression[i] === "'") return literal; + literal += expression[i]; + } + throw new Error(`unterminated literal in ${expression}`); +}; + +describe("pbFilter", () => { + it.each([ + ["a match-substitution pattern", "a$&b"], + ["a preceding-input pattern", "a$`b"], + ["a following-input pattern", "a$'b"], + ["an escaped-dollar pattern", "a$$b"], + ["a bare dollar", "a$b"], + ["nothing but a dollar", "$"], + ["a dollar beside a metacharacter", "50$%"], + ])("substitutes %s as a literal", (_label, term) => { + const expression = pbFilter(pb, "first_name ~ {:query}", { query: term }); + + expect(quotedLiteral(expression)).toBe(term); + expect(expression).not.toContain("{:query}"); + }); + + it("keeps both dollars of an escaped-dollar pattern", () => { + expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'"); + }); + + it("splices no part of the surrounding expression into the literal", () => { + const expression = pbFilter(pb, "first_name ~ {:query}", { + query: "a$`b", + }); + + expect(expression).toBe("first_name ~ 'a$`b'"); + expect(quotedLiteral(expression)).not.toContain("first_name"); + }); + + it.each([ + ["a single quote", "o'brien"], + ["a trailing backslash", "ada\\"], + ["a percent sign", "50%"], + ])("escapes %s exactly as the SDK does", (_label, term) => { + expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe( + pb.filter("f ~ {:q}", { q: term }) + ); + }); + + it.each([ + ["a number", 42], + ["a boolean", true], + ["null", null], + ["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))], + ])("renders %s identically to pb.filter", (_label, value) => { + expect(pbFilter(pb, "f = {:v}", { v: value })).toBe( + pb.filter("f = {:v}", { v: value }) + ); + }); + + it("leaves a placeholder with no matching parameter verbatim", () => { + expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}"); + expect(pbFilter(pb, "a = {:missing}", {})).toBe( + pb.filter("a = {:missing}", {}) + ); + }); + + it.each([ + ["a hyphen", "user-id"], + ["a dot", "meta.user"], + ["a digit and underscore", "auth_id2"], + ])("resolves a key containing %s the way the SDK does", (_label, key) => { + const raw = `a = {:${key}}`; + + expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'"); + expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" })); + }); + + it("does not absorb another parameter's value into a literal", () => { + expect( + pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" }) + ).toBe("a = '%{:y}%' && b = 'SECRET'"); + }); + + it("substitutes every occurrence of a repeated placeholder", () => { + expect( + pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" }) + ).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')"); + }); +}); + +// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade +// fixes the expansion upstream, these fail rather than the doubling silently +// becoming a double-escape. +describe("the pocketbase SDK substitution this works around", () => { + it("still mis-expands a dollar pattern in a parameter value", () => { + expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}"); + }); + + it("still splices a later parameter into an earlier literal", () => { + expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe( + "a = '%'SECRET'%' && b = 'SECRET'" + ); + }); +}); diff --git a/src/lib/pocketbase/util/filter.ts b/src/lib/pocketbase/util/filter.ts new file mode 100644 index 0000000..7cf03a3 --- /dev/null +++ b/src/lib/pocketbase/util/filter.ts @@ -0,0 +1,28 @@ +import type PocketBase from "pocketbase"; + +export type FilterParam = string | number | boolean | Date | null; + +// Any key the SDK's own `replaceAll("{:" + key + "}", …)` loop would substitute, +// so which keys resolve does not depend on the characters they are spelled with. +const PLACEHOLDER = /\{:([^}]+)\}/g; + +// `pb.filter` substitutes with String.replaceAll and a *string* replacement, so +// `$&`, `` $` ``, `$'` and `$$` inside a value are expanded as replacement +// patterns: the value's own text, or a slice of the surrounding expression, +// gets spliced into the quoted literal. Doubling every `$` first collapses back +// to the exact literal inside that same replaceAll. The FilterParam union is +// load-bearing — it keeps objects and arrays out of the SDK's JSON.stringify +// branch, which would reintroduce an undoubled `$`. +const quote = (pb: PocketBase, value: FilterParam) => + pb.filter("{:v}", { + v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value, + }); + +export const pbFilter = ( + pb: PocketBase, + raw: string, + params: Record +) => + raw.replace(PLACEHOLDER, (token, key: string) => + Object.hasOwn(params, key) ? quote(pb, params[key]) : token + ); diff --git a/src/lib/pocketbase/util/like-pattern.test.ts b/src/lib/pocketbase/util/like-pattern.test.ts new file mode 100644 index 0000000..d62ce91 --- /dev/null +++ b/src/lib/pocketbase/util/like-pattern.test.ts @@ -0,0 +1,69 @@ +import PocketBase from "pocketbase"; +import { describe, expect, it } from "vitest"; +import { literalTerminates } from "@/test/pb-filter"; +import { pbFilter } from "./filter"; +import { likePattern } from "./like-pattern"; + +const compose = (term: string) => + pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", { + query: likePattern(term), + }); + +describe("likePattern", () => { + it("wraps a plain term so ~ matches a substring", () => { + expect(likePattern("ada")).toBe("%ada%"); + }); + + it.each([ + ["a percent sign", "50%", "%50\\%%"], + ["an underscore", "_", "%\\_%"], + ["a backslash", "ada\\", "%ada\\\\%"], + ])("escapes %s so it matches literally", (_label, term, expected) => { + expect(likePattern(term)).toBe(expected); + }); + + it("always ends the operand with an unescaped wildcard", () => { + // An odd run of backslashes before the final % would mean the % is itself + // escaped, which is the shape that swallows the closing quote. + for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) { + const pattern = likePattern(term); + const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? ""; + + expect(pattern.endsWith("%")).toBe(true); + expect(trailingSlashes.length % 2).toBe(0); + } + }); +}); + +describe("likePattern composed through the real SDK", () => { + it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])( + "keeps the filter expression parseable for %j", + (term) => { + expect(literalTerminates(compose(term))).toBe(true); + } + ); + + it("detects the unterminated literal a raw term produces", () => { + const raw = new PocketBase("http://pocketbase.test").filter( + "first_name ~ {:query}", + { query: "ada\\" } + ); + + expect(raw).toBe("first_name ~ 'ada\\'"); + expect(literalTerminates(raw)).toBe(false); + }); + + it("leaves a term without metacharacters exactly as before", () => { + expect(compose("ada")).toBe("first_name ~ '%ada%'"); + }); + + it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])( + "carries %j through both escaping layers intact", + (term) => { + const composed = compose(term); + + expect(composed).toContain(likePattern(term)); + expect(literalTerminates(composed)).toBe(true); + } + ); +}); diff --git a/src/lib/pocketbase/util/like-pattern.ts b/src/lib/pocketbase/util/like-pattern.ts new file mode 100644 index 0000000..4e21e29 --- /dev/null +++ b/src/lib/pocketbase/util/like-pattern.ts @@ -0,0 +1,10 @@ +// `pb.filter()` escapes single quotes and nothing else, so a term ending in a +// backslash escapes the closing quote of the literal it is substituted into and +// PocketBase rejects the whole expression with 400 validation_invalid_filter. +// Escaping `\ % _` and appending the wildcards here keeps the operand's last +// character a literal `%`, and makes `~` an unconditional substring match: +// PocketBase only auto-wraps (and only auto-escapes) operands that contain no +// `%` of their own, so a term carrying one would otherwise silently become a +// prefix match, and a bare `_` would match every row. +export const likePattern = (term: string) => + `%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`; diff --git a/src/lib/redact.test.ts b/src/lib/redact.test.ts new file mode 100644 index 0000000..f4ab62e --- /dev/null +++ b/src/lib/redact.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { redactValue, SENSITIVE_KEY } from "./redact"; + +// Shared scrubber for logs + audit rows - a regression leaks PII from both. +describe("redactValue", () => { + it("redacts sensitive keys and preserves benign ones", () => { + expect( + redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" }) + ).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" }); + }); + + it("redacts recursively through nested objects and arrays", () => { + expect( + redactValue({ + user: { first_name: "Ada", password: "hunter2" }, + items: [{ authToken: "x" }, { label: "ok" }], + }) + ).toEqual({ + user: { first_name: "Ada", password: "[redacted]" }, + items: [{ authToken: "[redacted]" }, { label: "ok" }], + }); + }); + + it("passes primitives through untouched (a bare string is not assumed secret)", () => { + expect(redactValue("hello")).toBe("hello"); + expect(redactValue(42)).toBe(42); + expect(redactValue(null)).toBeNull(); + }); + + it("covers the documented sensitive keys", () => { + for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) { + expect(SENSITIVE_KEY.test(key)).toBe(true); + } + expect(SENSITIVE_KEY.test("first_name")).toBe(false); + }); +}); diff --git a/src/lib/redact.ts b/src/lib/redact.ts new file mode 100644 index 0000000..b97f07b --- /dev/null +++ b/src/lib/redact.ts @@ -0,0 +1,21 @@ +// Shared redaction for logs + audit rows so the two never drift. +export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i; + +const REDACTED = "[redacted]"; + +// Deep-redact: keys matching SENSITIVE_KEY become "[redacted]"; primitives pass through. +export const redactValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(redactValue); + if (value && typeof value === "object") { + // Keep Error serializable. + if (value instanceof Error) { + return { name: value.name, message: value.message, stack: value.stack }; + } + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v); + } + return out; + } + return value; +}; diff --git a/src/lib/tanstack-query/utils/to-server-result.ts b/src/lib/tanstack-query/utils/to-server-result.ts index fc1ea9b..b4af883 100644 --- a/src/lib/tanstack-query/utils/to-server-result.ts +++ b/src/lib/tanstack-query/utils/to-server-result.ts @@ -1,6 +1,5 @@ import { logger } from "../../logger"; import { ErrorType, ServerError, ServerResult } from "../types"; -import { getRequest } from "@tanstack/react-start/server"; import { isRedirect } from "@tanstack/react-router"; export const createServerError = ( @@ -17,57 +16,20 @@ export const createServerError = ( context, }); +// Audit rows are written by serverFnLoggingMiddleware, which reads the +// returned envelope's success flag — never write them here. export const toServerResult = async ( serverFn: () => Promise ): Promise> => { - const startTime = Date.now(); - try { const data = await serverFn(); return { success: true, data }; } catch (error) { if (isRedirect(error) || error instanceof Response) throw error; - const duration = Date.now() - startTime; logger.error('Server Fn Error', error); - const mappedError = mapKnownError(error); - - let fnName = 'unknown'; - try { - const request = getRequest(); - const url = new URL(request.url); - - const functionId = url.searchParams.get('_serverFnId') || url.pathname; - - if (functionId.includes('--')) { - const match = functionId.match(/--([^_]+)_/); - fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown'; - } else { - fnName = serverFn.name || 'unknown'; - } - } catch { - fnName = serverFn.name || 'unknown'; - } - - import("../../pocketbase/client") - .then(async ({ pbAdmin }) => { - await pbAdmin.authPromise; - await pbAdmin.createActivity({ - name: fnName, - duration, - success: false, - error: mappedError.message, - arguments: { - errorType: mappedError.code, - statusCode: mappedError.statusCode, - userMessage: mappedError.userMessage, - }, - }); - }) - .catch(() => {}); - - return { success: false, error: mappedError }; + return { success: false, error: mapKnownError(error) }; } }; diff --git a/src/test/pb-filter.ts b/src/test/pb-filter.ts new file mode 100644 index 0000000..0ec1a87 --- /dev/null +++ b/src/test/pb-filter.ts @@ -0,0 +1,14 @@ +// Mirrors PocketBase's own scan of a quoted literal: a backslash consumes the +// character after it, so an unterminated literal is exactly what a trailing +// backslash produces. +export const literalTerminates = (expression: string) => { + const open = expression.indexOf("'"); + for (let i = open + 1; i < expression.length; i++) { + if (expression[i] === "\\") { + i++; + continue; + } + if (expression[i] === "'") return true; + } + return false; +}; diff --git a/src/utils/activities.test.ts b/src/utils/activities.test.ts new file mode 100644 index 0000000..10c9149 --- /dev/null +++ b/src/utils/activities.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + request: undefined as { url: string; headers: Headers } | undefined, + createActivity: vi.fn(), +})); + +vi.mock("@tanstack/react-start/server", () => ({ + getRequest: () => h.request, +})); + +vi.mock("@/lib/pocketbase/client", () => ({ + pbAdmin: { authPromise: Promise.resolve(), createActivity: h.createActivity }, +})); + +vi.mock("@/lib/logger", () => ({ + Logger: class { + error() {} + info() {} + }, +})); + +import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities"; + +type ServerHandler = (opts: { + next: () => Promise; + data: unknown; + context: unknown; + serverFnMeta?: { id: string; name?: string; filename?: string }; +}) => Promise; + +const runMiddleware = (opts: Parameters[0]) => + (serverFnLoggingMiddleware as any).options.server(opts) as ReturnType; + +const setRequest = (url: string, userAgent = "vitest") => { + h.request = { url, headers: new Headers({ "user-agent": userAgent }) }; +}; + +const successEnvelope = { result: { success: true, data: {} } }; + +beforeEach(() => { + h.createActivity.mockReset(); +}); + +describe("serverFnLoggingMiddleware", () => { + it("records the source name from the compile-time meta, not the hashed url segment", async () => { + const hashedId = "a".repeat(64); + setRequest(`http://localhost:3000/_serverFn/${hashedId}`); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: { metadata: { player_id: "p1" } }, + serverFnMeta: { + id: hashedId, + name: "updatePlayer", + filename: "src/features/players/server.ts", + }, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "updatePlayer", + player: "p1", + success: true, + user_agent: "vitest", + }); + }); + + it("falls back to the last path segment when the meta carries no name", async () => { + setRequest("http://localhost:3000/_serverFn/deadbeef"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + serverFnMeta: { id: "deadbeef" }, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("deadbeef"); + }); + + it("falls back to the last path segment when there is no meta at all", async () => { + setRequest("http://localhost:3000/_serverFn/legacySegment"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("legacySegment"); + }); + + it("records no player when the session has no player_id", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].player).toBeUndefined(); + }); + + it("falls back to 'unknown' when the path has no trailing segment", async () => { + setRequest("http://localhost:3000/"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("unknown"); + }); + + it("records success:false by reading the returned envelope, not by throwing", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => ({ + result: { + success: false, + error: { code: "NOT_FOUND", userMessage: "nope" }, + }, + }), + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + const row = h.createActivity.mock.calls[0][0]; + expect(row.success).toBe(false); + expect(row.error).toContain("NOT_FOUND"); + }); + + it("records success:false and rethrows when the handler throws", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await expect( + runMiddleware({ + next: async () => { + throw new Error("boom"); + }, + data: undefined, + context: {}, + }) + ).rejects.toThrow("boom"); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + success: false, + error: "boom", + }); + }); + + it("redacts sensitive argument keys before they reach the audit row", async () => { + setRequest("http://localhost:3000/_serverFn/login"); + + await runMiddleware({ + next: async () => successEnvelope, + data: { phone: "+17135550142", first_name: "Ada" }, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].arguments).toEqual({ + phone: "[redacted]", + first_name: "Ada", + }); + }); + + it("truncates oversized arguments", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => successEnvelope, + data: { blob: "x".repeat(5000) }, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + const args = h.createActivity.mock.calls[0][0].arguments; + expect(args.truncated).toBeDefined(); + expect(args.truncated.length).toBeLessThanOrEqual(2048); + }); +}); + +describe("recordDeniedServerFn", () => { + const deniedRequest = (url: string) => + ({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request; + + it("names the denial row from the meta so it matches the success rows", async () => { + recordDeniedServerFn( + deniedRequest(`http://localhost:3000/_serverFn/${"b".repeat(64)}`), + { player_id: "p1" }, + { name: "createTournament" } + ); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "createTournament", + player: "p1", + success: false, + error: "FORBIDDEN: Access denied", + }); + }); + + it("still records a name when the meta is absent", async () => { + recordDeniedServerFn( + deniedRequest("http://localhost:3000/_serverFn/rawSegment") + ); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "rawSegment", + success: false, + }); + }); +}); diff --git a/src/utils/activities.ts b/src/utils/activities.ts index fc577dc..071e592 100644 --- a/src/utils/activities.ts +++ b/src/utils/activities.ts @@ -1,6 +1,7 @@ import { createMiddleware } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { Logger } from "@/lib/logger"; +import { redactValue } from "@/lib/redact"; import type { ActivityInput } from "@/lib/pocketbase/services/activities"; const logger = new Logger("Activities"); @@ -16,16 +17,67 @@ const recordActivity = (activity: ActivityInput) => { }); }; +const MAX_ARGUMENTS_CHARS = 2048; + +const redactArguments = (data: unknown): unknown => { + if (data === undefined) return undefined; + + const redacted = redactValue(data); + const serialized = JSON.stringify(redacted) ?? ""; + if (serialized.length > MAX_ARGUMENTS_CHARS) { + return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) }; + } + return redacted; +}; + +const auditErrorMessage = ( + error?: { code?: string; userMessage?: string } +): string => { + if (!error) return "error"; + return [error.code, error.userMessage].filter(Boolean).join(": ") || "error"; +}; + +type Actor = { player_id?: string }; + +type ServerFnMeta = { name?: string }; + +// Start's default generateFunctionId hashes the entry id, so the URL segment is +// a sha256 and the compile-time meta is the only readable name. The path stays +// as the fallback for requests that carry no meta. +const serverFnName = (request: Request, meta?: ServerFnMeta): string => { + if (meta?.name) return meta.name; + const pathParts = new URL(request.url).pathname.split("/").filter(Boolean); + return pathParts[pathParts.length - 1] || "unknown"; +}; + +// The admin middleware refuses before serverFnLoggingMiddleware ever runs, so +// a denial has to write its own row — otherwise an operator reviewing the log +// during a probing attempt sees a clean history. The rejected payload is left +// out: it never reached a validator, so it is unbounded attacker input. +export const recordDeniedServerFn = ( + request: Request, + actor?: Actor, + meta?: ServerFnMeta +) => { + recordActivity({ + name: serverFnName(request, meta), + player: actor?.player_id, + duration: 0, + success: false, + error: "FORBIDDEN: Access denied", + user_agent: request.headers.get("user-agent") || undefined, + }); +}; + export const serverFnLoggingMiddleware = createMiddleware({ type: "function", -}).server(async ({ next, data, context }) => { +}).server(async ({ next, data, context, serverFnMeta }) => { const request = getRequest(); - const url = new URL(request.url); - const pathParts = url.pathname.split('/').filter(Boolean); - const serverFnName = pathParts[pathParts.length - 1] || 'unknown'; - const userId = (context as any)?.metadata?.player_id || 'unknown'; - const userAgent = request.headers.get('user-agent') || undefined; + const name = serverFnName(request, serverFnMeta); + const playerId = (context as any)?.metadata?.player_id as string | undefined; + const userAgent = request.headers.get("user-agent") || undefined; + const safeArgs = redactArguments(data); const startTime = Date.now(); @@ -33,12 +85,26 @@ export const serverFnLoggingMiddleware = createMiddleware({ const result = await next(); const duration = Date.now() - startTime; + // Single audit writer; toServerResult resolves { success:false } instead of throwing, so read the flag. + const envelope = (result as { result?: unknown })?.result; + const failed = + !!envelope && + typeof envelope === "object" && + "success" in envelope && + (envelope as { success: boolean }).success === false; + recordActivity({ - name: serverFnName, - player: userId !== 'unknown' ? userId : undefined, + name, + player: playerId, duration, - success: true, - arguments: data, + success: !failed, + error: failed + ? auditErrorMessage( + (envelope as { error?: { code?: string; userMessage?: string } }) + .error + ) + : undefined, + arguments: safeArgs, user_agent: userAgent, }); @@ -48,12 +114,12 @@ export const serverFnLoggingMiddleware = createMiddleware({ const errorMessage = error instanceof Error ? error.message : String(error); recordActivity({ - name: serverFnName, - player: userId !== 'unknown' ? userId : undefined, + name, + player: playerId, duration, success: false, error: errorMessage, - arguments: data, + arguments: safeArgs, user_agent: userAgent, }); diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 1382a80..19f47bc 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({ type: "function", -}).server(async ({ next }) => { +}).server(async ({ next, serverFnMeta }) => { const request = getRequest(); try { @@ -62,7 +62,13 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({ return next({ context }); } - logger.error("Unauthorized user in admin function.", context); + // Identifiers only — the full context carries phone + metadata. + logger.error("Unauthorized user in admin function.", { + userAuthId: context.userAuthId, + roles: context.roles, + }); + const { recordDeniedServerFn } = await import("./activities"); + recordDeniedServerFn(request, context.metadata, serverFnMeta); throw new Error("Unauthorized"); } catch (error: any) { if (error.message === "SESSION_REFRESH_REQUIRED") { diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..209e4d0 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +// Excludes the TanStack Start plugin: its SSR/router codegen isn't needed for unit tests. +export default defineConfig({ + plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })], + test: { + environment: 'node', + include: ['src/**/*.test.{ts,tsx}'], + restoreMocks: true, + }, +}) From 11bb70316ad454d1ba7c01db30d3d43a01dfc634 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 19:03:57 -0700 Subject: [PATCH 5/8] fix(pwa): simplify the install prompt copy --- src/components/ios-install-prompt.tsx | 2 +- src/locales/de/messages.po | 4 ++-- src/locales/en/messages.po | 4 ++-- src/locales/es/messages.po | 4 ++-- src/locales/ja/messages.po | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx index 6b4a8f6..66e0f70 100644 --- a/src/components/ios-install-prompt.tsx +++ b/src/components/ios-install-prompt.tsx @@ -58,7 +58,7 @@ export function IOSInstallPrompt() { - Add FLXN to your home screen — smoother experience, always one tap away + Add FLXN to your home screen for a smoother experience {instructions} diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po index 3e39176..4c87ede 100644 --- a/src/locales/de/messages.po +++ b/src/locales/de/messages.po @@ -393,8 +393,8 @@ msgid "Activity Details" msgstr "Aktivitätsdetails" #: src/components/ios-install-prompt.tsx:61 -msgid "Add FLXN to your home screen — smoother experience, always one tap away" -msgstr "Leg FLXN auf deinen Homescreen — läuft flüssiger und ist immer nur einen Tipp entfernt" +msgid "Add FLXN to your home screen for a smoother experience" +msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis" #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index c746078..5547461 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -393,8 +393,8 @@ msgid "Activity Details" msgstr "Activity Details" #: src/components/ios-install-prompt.tsx:61 -msgid "Add FLXN to your home screen — smoother experience, always one tap away" -msgstr "Add FLXN to your home screen — smoother experience, always one tap away" +msgid "Add FLXN to your home screen for a smoother experience" +msgstr "Add FLXN to your home screen for a smoother experience" #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po index 1a0c931..c4ae547 100644 --- a/src/locales/es/messages.po +++ b/src/locales/es/messages.po @@ -393,8 +393,8 @@ msgid "Activity Details" msgstr "Detalles de la actividad" #: src/components/ios-install-prompt.tsx:61 -msgid "Add FLXN to your home screen — smoother experience, always one tap away" -msgstr "Agrega FLXN a tu pantalla de inicio — funciona mejor y siempre está a un toque" +msgid "Add FLXN to your home screen for a smoother experience" +msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida" #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po index 2c73643..b3100e7 100644 --- a/src/locales/ja/messages.po +++ b/src/locales/ja/messages.po @@ -393,8 +393,8 @@ msgid "Activity Details" msgstr "アクティビティの詳細" #: src/components/ios-install-prompt.tsx:61 -msgid "Add FLXN to your home screen — smoother experience, always one tap away" -msgstr "FLXNをホーム画面に追加。もっと快適に使えて、いつでもワンタップで開けます" +msgid "Add FLXN to your home screen for a smoother experience" +msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます" #: src/features/tournaments/components/edit-enrolled-players.tsx:117 msgid "Add Player" From 792425a83ed9287441e7e4ec81f31441c5443cb7 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 19:47:31 -0700 Subject: [PATCH 6/8] fix(i18n): hydrate in the SSR locale instead of regressing to en MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client's message cache held only en at hydration, so a non-en user rendered English until the locale's catalog chunk loaded — and stayed English if that import failed or lost a race. __root beforeLoad now dehydrates the active locale's compiled catalog with the page and the provider primes its cache from that payload, so first paint is already in the user's locale with no dependency on a client-side chunk load. ensureMessages dedupes concurrent loads and drops rejected ones so a retry can succeed, and the provider's fallback loader retries instead of silently stranding the UI in en. --- src/app/routes/__root.tsx | 19 +++++++++++++++-- src/lib/i18n/index.ts | 24 +++++++++++++++++++--- src/lib/i18n/provider.tsx | 43 +++++++++++++++++++++++++++++++-------- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index b9eb8f0..3c20be8 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n"; +import { + DEFAULT_LOCALE, + ensureMessages, + getCachedMessages, + i18nMessagesQueryKey, + resolveLocale, +} from "@/lib/i18n"; import { getRootMeta } from "@/lib/i18n/meta"; import FullScreenLoader from "@/components/full-screen-loader"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; @@ -132,7 +138,16 @@ export const Route = createRootRouteWithContext<{ context.queryClient, playerQueries.auth() ); - await ensureMessages(resolveLocale(auth?.metadata?.locale)); + const locale = resolveLocale(auth?.metadata?.locale); + await ensureMessages(locale); + // Dehydrate the catalog with the page so the client hydrates in the + // active locale instead of falling back to en until a chunk loads. + if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) { + context.queryClient.setQueryData( + i18nMessagesQueryKey(locale), + getCachedMessages(locale) + ); + } return { auth }; } catch (error: any) { if (isRedirect(error) || error instanceof Response) throw error; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index d73d472..af9eafa 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale { : DEFAULT_LOCALE; } +const inflightLoads: Partial>> = {}; + export async function ensureMessages(locale: AppLocale): Promise { const cached = messageCache[locale]; if (cached) return cached; @@ -39,11 +41,27 @@ export async function ensureMessages(locale: AppLocale): Promise { const loader = catalogLoaders[`/src/locales/${locale}/messages.po`]; if (!loader) return messageCache[DEFAULT_LOCALE]!; - const { messages } = await loader(); - messageCache[locale] = messages; - return messages; + // Dedupe concurrent loads; a rejected load is dropped so a retry can succeed. + const pending = (inflightLoads[locale] ??= loader() + .then(({ messages }) => { + messageCache[locale] = messages; + return messages; + }) + .finally(() => { + delete inflightLoads[locale]; + })); + return pending; } +export function primeMessages(locale: AppLocale, messages: Messages) { + messageCache[locale] ??= messages; +} + +// Seeded into the SSR-dehydrated query cache so hydration renders the active +// locale without depending on a client-side catalog chunk load. +export const i18nMessagesQueryKey = (locale: AppLocale) => + ["i18n-messages", locale] as const; + export function getCachedMessages(locale: AppLocale): Messages { return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!; } diff --git a/src/lib/i18n/provider.tsx b/src/lib/i18n/provider.tsx index 424aef5..312a653 100644 --- a/src/lib/i18n/provider.tsx +++ b/src/lib/i18n/provider.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { I18nProvider } from "@lingui/react"; +import type { Messages } from "@lingui/core"; import { playerQueries } from "@/features/players/queries"; import { createI18n, ensureMessages, hasCachedMessages, + i18nMessagesQueryKey, + primeMessages, resolveLocale, } from "@/lib/i18n"; @@ -29,27 +32,49 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => { refetchOnWindowFocus: false, }); + const queryClient = useQueryClient(); const locale = resolveLocale(data?.metadata?.locale); const [catalogVersion, setCatalogVersion] = useState(0); // Fresh instance per locale (and per SSR request tree). __root beforeLoad - // preloads the catalog on authed routes; on public routes (login) it may - // not be cached yet — createI18n falls back to en until the effect below - // loads it and bumps catalogVersion. + // dehydrates the active locale's catalog with the page, so hydration primes + // it from the query cache and never regresses to en for the SSR'd locale. + // Public routes (login) may still lack it — createI18n falls back to en + // until the effect below loads it and bumps catalogVersion. const i18n = useMemo( - () => createI18n(locale), + () => { + if (!hasCachedMessages(locale)) { + const seeded = queryClient.getQueryData( + i18nMessagesQueryKey(locale) + ); + if (seeded) primeMessages(locale, seeded); + } + return createI18n(locale); + }, // eslint-disable-next-line react-hooks/exhaustive-deps - [locale, catalogVersion] + [locale, catalogVersion, queryClient] ); useEffect(() => { if (hasCachedMessages(locale)) return; let cancelled = false; - ensureMessages(locale).then(() => { - if (!cancelled) setCatalogVersion((v) => v + 1); - }); + let retryTimer: ReturnType | undefined; + // A failed chunk load must not strand the UI in en — retry until the + // catalog arrives or the locale changes. + const load = () => { + ensureMessages(locale).then( + () => { + if (!cancelled) setCatalogVersion((v) => v + 1); + }, + () => { + if (!cancelled) retryTimer = setTimeout(load, 3000); + } + ); + }; + load(); return () => { cancelled = true; + if (retryTimer) clearTimeout(retryTimer); }; }, [locale]); From c342b9102945bc8cb4f255c9d69c652be0a9b797 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 20:06:05 -0700 Subject: [PATCH 7/8] feat(header): shrink long titles to fit instead of wrapping The header keeps its size; a title that would overflow scales down to a floor of 55% and ellipsizes only past that. Re-measures on title change, resize, and web-font load. --- src/features/core/components/header.tsx | 58 ++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx index e393091..6aca616 100644 --- a/src/features/core/components/header.tsx +++ b/src/features/core/components/header.tsx @@ -1,15 +1,57 @@ +import { useLayoutEffect, useRef } from "react"; import { Title, AppShell, Flex } from "@mantine/core"; import { useLingui } from "@lingui/react"; import { HeaderConfig } from "../types/header-config"; import BackButton from "./back-button"; +const MIN_TITLE_SCALE = 0.55; + const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => { const { i18n } = useLingui(); + const titleRef = useRef(null); const resolvedTitle = typeof title === "string" || title === undefined ? title : i18n._({ ...title, values: titleValues }); + useLayoutEffect(() => { + const el = titleRef.current; + if (!el) return; + let cancelled = false; + + const textWidth = () => { + const range = document.createRange(); + range.selectNodeContents(el); + return range.getBoundingClientRect().width; + }; + + const fit = () => { + el.style.fontSize = ""; + const available = el.clientWidth - 1; + if (available <= 0) return; + let needed = textWidth(); + if (needed <= available) return; + const natural = parseFloat(getComputedStyle(el).fontSize); + let scale = Math.max(available / needed, MIN_TITLE_SCALE); + el.style.fontSize = `${natural * scale}px`; + for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) { + scale = Math.max(scale * 0.98, MIN_TITLE_SCALE); + el.style.fontSize = `${natural * scale}px`; + } + }; + + fit(); + document.fonts?.ready.then(() => { + if (!cancelled) fit(); + }); + const observer = new ResizeObserver(fit); + observer.observe(el); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [resolvedTitle]); + return ( { withBackButton && } - - + <Flex justify='center' px={48} mt={8} w='100%'> + <Title + ref={titleRef} + order={1} + lts='0.08em' + style={{ + userSelect: 'none', + width: '100%', + textAlign: 'center', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > {resolvedTitle?.toLocaleUpperCase()} From 58702da156b2d20dccbae151d8b3c32e3ee95d76 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 23 Aug 2026 20:06:05 -0700 Subject: [PATCH 8/8] style: trim narration comments from recent changes --- src/app/routes/__root.tsx | 2 -- src/components/ios-install-prompt.tsx | 3 --- src/features/core/hooks/use-visual-viewport-size.ts | 3 --- src/lib/i18n/index.ts | 3 --- src/lib/i18n/provider.tsx | 7 ------- src/lib/pocketbase/client.ts | 12 ++---------- src/lib/pocketbase/util/filter.ts | 10 +--------- src/lib/pocketbase/util/like-pattern.ts | 9 +-------- src/lib/redact.ts | 3 --- src/lib/tanstack-query/utils/to-server-result.ts | 2 -- src/locales/de/messages.po | 8 ++++---- src/locales/en/messages.po | 8 ++++---- src/locales/es/messages.po | 8 ++++---- src/locales/ja/messages.po | 8 ++++---- src/utils/activities.ts | 9 +-------- src/utils/supertokens.ts | 1 - 16 files changed, 21 insertions(+), 75 deletions(-) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 3c20be8..ba1d90b 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -140,8 +140,6 @@ export const Route = createRootRouteWithContext<{ ); const locale = resolveLocale(auth?.metadata?.locale); await ensureMessages(locale); - // Dehydrate the catalog with the page so the client hydrates in the - // active locale instead of falling back to en until a chunk loads. if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) { context.queryClient.setQueryData( i18nMessagesQueryKey(locale), diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx index 66e0f70..e6f17d5 100644 --- a/src/components/ios-install-prompt.tsx +++ b/src/components/ios-install-prompt.tsx @@ -5,7 +5,6 @@ import { Trans, useLingui } from '@lingui/react/macro' import { useAuth } from '@/contexts/auth-context' import { useIsMobile } from '@/hooks/use-is-mobile' -// Navbar geometry: 4rem height + 0.5rem margin + its safe-area bottom offset. const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' export function IOSInstallPrompt() { @@ -43,8 +42,6 @@ export function IOSInstallPrompt() { ? t`Tap Share → Add to Home Screen` : t`Tap Menu (⋮) → Add to Home screen` - // Navbar renders only under _authed, so a signed-in mobile user on a public - // route has no nav to clear. const aboveBottomNav = Boolean(user) && isMobile const bottomStyle = aboveBottomNav ? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' } diff --git a/src/features/core/hooks/use-visual-viewport-size.ts b/src/features/core/hooks/use-visual-viewport-size.ts index dfc3a94..b8051a1 100644 --- a/src/features/core/hooks/use-visual-viewport-size.ts +++ b/src/features/core/hooks/use-visual-viewport-size.ts @@ -5,9 +5,6 @@ const eventListerOptions = { }; const useVisualViewportSize = () => { - // Starts at zero on server and client alike, filling in after mount — the - // same contract as Mantine's useViewportSize. Reading window during render - // makes the client's first render disagree with the SSR markup. const [windowSize, setWindowSize] = useState({ width: 0, height: 0, diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index af9eafa..0af5972 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -41,7 +41,6 @@ export async function ensureMessages(locale: AppLocale): Promise { const loader = catalogLoaders[`/src/locales/${locale}/messages.po`]; if (!loader) return messageCache[DEFAULT_LOCALE]!; - // Dedupe concurrent loads; a rejected load is dropped so a retry can succeed. const pending = (inflightLoads[locale] ??= loader() .then(({ messages }) => { messageCache[locale] = messages; @@ -57,8 +56,6 @@ export function primeMessages(locale: AppLocale, messages: Messages) { messageCache[locale] ??= messages; } -// Seeded into the SSR-dehydrated query cache so hydration renders the active -// locale without depending on a client-side catalog chunk load. export const i18nMessagesQueryKey = (locale: AppLocale) => ["i18n-messages", locale] as const; diff --git a/src/lib/i18n/provider.tsx b/src/lib/i18n/provider.tsx index 312a653..c229b87 100644 --- a/src/lib/i18n/provider.tsx +++ b/src/lib/i18n/provider.tsx @@ -36,11 +36,6 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => { const locale = resolveLocale(data?.metadata?.locale); const [catalogVersion, setCatalogVersion] = useState(0); - // Fresh instance per locale (and per SSR request tree). __root beforeLoad - // dehydrates the active locale's catalog with the page, so hydration primes - // it from the query cache and never regresses to en for the SSR'd locale. - // Public routes (login) may still lack it — createI18n falls back to en - // until the effect below loads it and bumps catalogVersion. const i18n = useMemo( () => { if (!hasCachedMessages(locale)) { @@ -59,8 +54,6 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => { if (hasCachedMessages(locale)) return; let cancelled = false; let retryTimer: ReturnType | undefined; - // A failed chunk load must not strand the UI in en — retry until the - // catalog arrives or the locale changes. const load = () => { ensureMessages(locale).then( () => { diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts index 032bb5f..cf717a9 100644 --- a/src/lib/pocketbase/client.ts +++ b/src/lib/pocketbase/client.ts @@ -19,17 +19,13 @@ class PocketBaseAdminClient { this.pb = new PocketBase(process.env.POCKETBASE_URL); this.pb.beforeSend = async (url, options) => { - // The auth requests themselves must skip the gate below: gating them on - // authPromise would make them await their own completion (deadlock). + // Auth requests skip the gate: awaiting authPromise here would deadlock them. if (!url.includes("/collections/_superusers/auth-")) { try { await this.authPromise; - } catch { - // Swallow: fall through to self-heal so a rejected authPromise can't strand every request. - } + } catch {} if (!this.pb.authStore.isValid) { - // Self-heal: re-auth once PocketBase is reachable again, no restart. this.authPromise = this.authenticate(); await this.authPromise; this.startTokenRefresh(); @@ -43,8 +39,6 @@ class PocketBaseAdminClient { } } - // The SDK stamps Authorization before this hook runs, so a request - // built while auth was still in flight carries no (or a stale) token. options.headers = { ...options.headers, Authorization: this.pb.authStore.token, @@ -75,8 +69,6 @@ class PocketBaseAdminClient { Object.assign(this, createPushService(this.pb)); this.authPromise = this.authenticate(); - // Fail soft at boot: an unreachable PocketBase must not crash the process; - // beforeSend self-heals on the next request. this.authPromise .then(() => { this.startTokenRefresh(); diff --git a/src/lib/pocketbase/util/filter.ts b/src/lib/pocketbase/util/filter.ts index 7cf03a3..b4bf63a 100644 --- a/src/lib/pocketbase/util/filter.ts +++ b/src/lib/pocketbase/util/filter.ts @@ -2,17 +2,9 @@ import type PocketBase from "pocketbase"; export type FilterParam = string | number | boolean | Date | null; -// Any key the SDK's own `replaceAll("{:" + key + "}", …)` loop would substitute, -// so which keys resolve does not depend on the characters they are spelled with. const PLACEHOLDER = /\{:([^}]+)\}/g; -// `pb.filter` substitutes with String.replaceAll and a *string* replacement, so -// `$&`, `` $` ``, `$'` and `$$` inside a value are expanded as replacement -// patterns: the value's own text, or a slice of the surrounding expression, -// gets spliced into the quoted literal. Doubling every `$` first collapses back -// to the exact literal inside that same replaceAll. The FilterParam union is -// load-bearing — it keeps objects and arrays out of the SDK's JSON.stringify -// branch, which would reintroduce an undoubled `$`. +// pb.filter $-expands string replacements; doubling `$` keeps the value literal. const quote = (pb: PocketBase, value: FilterParam) => pb.filter("{:v}", { v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value, diff --git a/src/lib/pocketbase/util/like-pattern.ts b/src/lib/pocketbase/util/like-pattern.ts index 4e21e29..f27e61e 100644 --- a/src/lib/pocketbase/util/like-pattern.ts +++ b/src/lib/pocketbase/util/like-pattern.ts @@ -1,10 +1,3 @@ -// `pb.filter()` escapes single quotes and nothing else, so a term ending in a -// backslash escapes the closing quote of the literal it is substituted into and -// PocketBase rejects the whole expression with 400 validation_invalid_filter. -// Escaping `\ % _` and appending the wildcards here keeps the operand's last -// character a literal `%`, and makes `~` an unconditional substring match: -// PocketBase only auto-wraps (and only auto-escapes) operands that contain no -// `%` of their own, so a term carrying one would otherwise silently become a -// prefix match, and a bare `_` would match every row. +// Escapes LIKE metacharacters and wraps in `%` for a literal substring match. export const likePattern = (term: string) => `%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`; diff --git a/src/lib/redact.ts b/src/lib/redact.ts index b97f07b..459a0f0 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -1,13 +1,10 @@ -// Shared redaction for logs + audit rows so the two never drift. export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i; const REDACTED = "[redacted]"; -// Deep-redact: keys matching SENSITIVE_KEY become "[redacted]"; primitives pass through. export const redactValue = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(redactValue); if (value && typeof value === "object") { - // Keep Error serializable. if (value instanceof Error) { return { name: value.name, message: value.message, stack: value.stack }; } diff --git a/src/lib/tanstack-query/utils/to-server-result.ts b/src/lib/tanstack-query/utils/to-server-result.ts index b4af883..9fd5e78 100644 --- a/src/lib/tanstack-query/utils/to-server-result.ts +++ b/src/lib/tanstack-query/utils/to-server-result.ts @@ -16,8 +16,6 @@ export const createServerError = ( context, }); -// Audit rows are written by serverFnLoggingMiddleware, which reads the -// returned envelope's success flag — never write them here. export const toServerResult = async ( serverFn: () => Promise ): Promise> => { diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po index 4c87ede..be72f95 100644 --- a/src/locales/de/messages.po +++ b/src/locales/de/messages.po @@ -392,7 +392,7 @@ msgstr "Aktivitäten" msgid "Activity Details" msgstr "Aktivitätsdetails" -#: src/components/ios-install-prompt.tsx:61 +#: src/components/ios-install-prompt.tsx:58 msgid "Add FLXN to your home screen for a smoother experience" msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis" @@ -845,7 +845,7 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich" msgid "Disconnect Spotify" msgstr "Spotify trennen" -#: src/components/ios-install-prompt.tsx:68 +#: src/components/ios-install-prompt.tsx:65 msgid "Dismiss" msgstr "Schließen" @@ -2324,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Tippe unten auf einen Gegner zum Vergleichen" -#: src/components/ios-install-prompt.tsx:44 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen" -#: src/components/ios-install-prompt.tsx:43 +#: src/components/ios-install-prompt.tsx:42 msgid "Tap Share → Add to Home Screen" msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen" diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index 5547461..d384d47 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -392,7 +392,7 @@ msgstr "Activities" msgid "Activity Details" msgstr "Activity Details" -#: src/components/ios-install-prompt.tsx:61 +#: src/components/ios-install-prompt.tsx:58 msgid "Add FLXN to your home screen for a smoother experience" msgstr "Add FLXN to your home screen for a smoother experience" @@ -845,7 +845,7 @@ msgstr "deviceId is required for transfer action" msgid "Disconnect Spotify" msgstr "Disconnect Spotify" -#: src/components/ios-install-prompt.tsx:68 +#: src/components/ios-install-prompt.tsx:65 msgid "Dismiss" msgstr "Dismiss" @@ -2324,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Tap an opponent below to compare" -#: src/components/ios-install-prompt.tsx:44 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Tap Menu (⋮) → Add to Home screen" -#: src/components/ios-install-prompt.tsx:43 +#: src/components/ios-install-prompt.tsx:42 msgid "Tap Share → Add to Home Screen" msgstr "Tap Share → Add to Home Screen" diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po index c4ae547..9c62382 100644 --- a/src/locales/es/messages.po +++ b/src/locales/es/messages.po @@ -392,7 +392,7 @@ msgstr "Actividades" msgid "Activity Details" msgstr "Detalles de la actividad" -#: src/components/ios-install-prompt.tsx:61 +#: src/components/ios-install-prompt.tsx:58 msgid "Add FLXN to your home screen for a smoother experience" msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida" @@ -845,7 +845,7 @@ msgstr "Se requiere deviceId para la acción de transferencia" msgid "Disconnect Spotify" msgstr "Desconectar Spotify" -#: src/components/ios-install-prompt.tsx:68 +#: src/components/ios-install-prompt.tsx:65 msgid "Dismiss" msgstr "Cerrar" @@ -2324,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "Toca un oponente abajo para comparar" -#: src/components/ios-install-prompt.tsx:44 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio" -#: src/components/ios-install-prompt.tsx:43 +#: src/components/ios-install-prompt.tsx:42 msgid "Tap Share → Add to Home Screen" msgstr "Toca Compartir → Agregar a pantalla de inicio" diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po index b3100e7..65a6977 100644 --- a/src/locales/ja/messages.po +++ b/src/locales/ja/messages.po @@ -392,7 +392,7 @@ msgstr "アクティビティ" msgid "Activity Details" msgstr "アクティビティの詳細" -#: src/components/ios-install-prompt.tsx:61 +#: src/components/ios-install-prompt.tsx:58 msgid "Add FLXN to your home screen for a smoother experience" msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます" @@ -845,7 +845,7 @@ msgstr "転送操作にはdeviceIdが必要です" msgid "Disconnect Spotify" msgstr "Spotifyの連携を解除" -#: src/components/ios-install-prompt.tsx:68 +#: src/components/ios-install-prompt.tsx:65 msgid "Dismiss" msgstr "閉じる" @@ -2324,11 +2324,11 @@ msgstr "T" msgid "Tap an opponent below to compare" msgstr "下の対戦相手をタップして比較" -#: src/components/ios-install-prompt.tsx:44 +#: src/components/ios-install-prompt.tsx:43 msgid "Tap Menu (⋮) → Add to Home screen" msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」" -#: src/components/ios-install-prompt.tsx:43 +#: src/components/ios-install-prompt.tsx:42 msgid "Tap Share → Add to Home Screen" msgstr "共有をタップ →「ホーム画面に追加」" diff --git a/src/utils/activities.ts b/src/utils/activities.ts index 071e592..fe4507a 100644 --- a/src/utils/activities.ts +++ b/src/utils/activities.ts @@ -41,19 +41,13 @@ type Actor = { player_id?: string }; type ServerFnMeta = { name?: string }; -// Start's default generateFunctionId hashes the entry id, so the URL segment is -// a sha256 and the compile-time meta is the only readable name. The path stays -// as the fallback for requests that carry no meta. +// The URL segment is a hash of the fn id; serverFnMeta carries the readable name. const serverFnName = (request: Request, meta?: ServerFnMeta): string => { if (meta?.name) return meta.name; const pathParts = new URL(request.url).pathname.split("/").filter(Boolean); return pathParts[pathParts.length - 1] || "unknown"; }; -// The admin middleware refuses before serverFnLoggingMiddleware ever runs, so -// a denial has to write its own row — otherwise an operator reviewing the log -// during a probing attempt sees a clean history. The rejected payload is left -// out: it never reached a validator, so it is unbounded attacker input. export const recordDeniedServerFn = ( request: Request, actor?: Actor, @@ -85,7 +79,6 @@ export const serverFnLoggingMiddleware = createMiddleware({ const result = await next(); const duration = Date.now() - startTime; - // Single audit writer; toServerResult resolves { success:false } instead of throwing, so read the flag. const envelope = (result as { result?: unknown })?.result; const failed = !!envelope && diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 19f47bc..b8b7c4f 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -62,7 +62,6 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({ return next({ context }); } - // Identifiers only — the full context carries phone + metadata. logger.error("Unauthorized user in admin function.", { userAuthId: context.userAuthId, roles: context.roles,