diff --git a/apps/app-proxy/src/index.ts b/apps/app-proxy/src/index.ts index fb256288..f4263358 100644 --- a/apps/app-proxy/src/index.ts +++ b/apps/app-proxy/src/index.ts @@ -98,16 +98,12 @@ export default { const landingAsset = await proxyLandingAsset(request); if (landingAsset) return landingAsset; - // There is no separate privacy policy — the Terms of Service cover data - // handling. Store listings and old links point at /privacy, which no SPA - // has a route for, so it rendered an in-app not-found. Redirect at the - // edge so the client never loads the wrong route at all; web and - // web-mobile also carry their own /privacy -> /tos route for direct hits - // on rocksky.pages.dev / m.rocksky.app and for client-side navigation. - if (url.pathname === '/privacy' || url.pathname === '/privacy-policy') { - const tos = new URL(request.url); - tos.pathname = '/tos'; - return Response.redirect(tos.toString(), 301); + // /privacy is a real page in web and web-mobile, so it is NOT redirected. + // Only the alternate spelling is, for links that guessed the other name. + if (url.pathname === '/privacy-policy') { + const privacy = new URL(request.url); + privacy.pathname = '/privacy'; + return Response.redirect(privacy.toString(), 301); } // Serve rockbox-wasm runtime assets (audio worklet + decoder worker + the diff --git a/apps/app-proxy/test/privacy-redirect.bun.ts b/apps/app-proxy/test/privacy-redirect.bun.ts index 1fb976a9..ea8d8d12 100644 --- a/apps/app-proxy/test/privacy-redirect.bun.ts +++ b/apps/app-proxy/test/privacy-redirect.bun.ts @@ -4,31 +4,46 @@ import worker from '../src/index'; const call = (url: string, init?: RequestInit) => (worker as any).fetch(new Request(url, init), {} as never, {} as never) as Promise; -describe('privacy redirect', () => { - test('redirects /privacy to /tos on the same host', async () => { - const response = await call('https://rocksky.app/privacy'); +describe('privacy routing', () => { + test('/privacy is served by the app, never redirected', async () => { + // A redirect here would shadow the real privacy page in web / web-mobile, + // so assert the proxy does not claim the path. Falling through needs the + // network, so stub it and check where the request was pointed instead. + const originalFetch = globalThis.fetch; + let target = ''; + globalThis.fetch = (async (input: unknown) => { + const requested = String(input); + // The OG lookup is a separate hop; let it miss so the shell is returned as-is. + if (requested.includes('/public/og')) return new Response('missing', { status: 404 }); + target = requested; + return new Response('', { headers: { 'Content-Type': 'text/html' } }); + }) as unknown as typeof fetch; + try { + const response = await call('https://rocksky.app/privacy'); + expect(response.status).toBe(200); + expect(target).toContain('/privacy'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('redirects the alternate /privacy-policy spelling to /privacy', async () => { + const response = await call('https://rocksky.app/privacy-policy'); expect(response.status).toBe(301); - expect(response.headers.get('location')).toBe('https://rocksky.app/tos'); + expect(response.headers.get('location')).toBe('https://rocksky.app/privacy'); }); - test('redirects /privacy-policy too, and keeps the query string', async () => { + test('keeps the query string on that redirect', async () => { const response = await call('https://rocksky.app/privacy-policy?from=play'); expect(response.status).toBe(301); - expect(response.headers.get('location')).toBe('https://rocksky.app/tos?from=play'); + expect(response.headers.get('location')).toBe('https://rocksky.app/privacy?from=play'); }); - test('redirects before any user-agent routing, so mobile gets it too', async () => { - const response = await call('https://rocksky.app/privacy', { + test('redirects before user-agent routing, so mobile gets it too', async () => { + const response = await call('https://rocksky.app/privacy-policy', { headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' }, }); expect(response.status).toBe(301); - expect(response.headers.get('location')).toBe('https://rocksky.app/tos'); - }); - - test('leaves an unrelated path alone', async () => { - // Not a redirect: it falls through to the proxying path, which would need - // the network. Assert only that the privacy branch did not claim it. - const url = new URL('https://rocksky.app/privacy-settings'); - expect(url.pathname === '/privacy' || url.pathname === '/privacy-policy').toBe(false); + expect(response.headers.get('location')).toBe('https://rocksky.app/privacy'); }); }); diff --git a/apps/shared/legal.ts b/apps/shared/legal.ts new file mode 100644 index 00000000..bb61286b --- /dev/null +++ b/apps/shared/legal.ts @@ -0,0 +1,13 @@ +export type LegalBlock = + | { type: "text"; text: string } + | { type: "list"; items: string[] } + | { type: "contact"; text: string; email: string }; + +export type LegalSection = { title: string; blocks: LegalBlock[] }; + +export type LegalDocument = { + title: string; + lastUpdated: string; + intro: string; + sections: LegalSection[]; +}; diff --git a/apps/shared/privacy.ts b/apps/shared/privacy.ts new file mode 100644 index 00000000..3c167675 --- /dev/null +++ b/apps/shared/privacy.ts @@ -0,0 +1,315 @@ +import type { LegalDocument, LegalSection } from "./legal"; + +const sections: LegalSection[] = [ + { + title: "1. The short version", + blocks: [ + { + type: "list", + items: [ + "Rocksky is built on the AT Protocol. Your scrobbles, likes, playlists, shouts and follows are written to your own repository as public records.", + "Public means public: anyone can read them, and other services can keep their own copies.", + "Uploaded audio files are private to your account.", + "Scrobbling from other apps on your phone is off until you turn it on, and you can exclude individual apps.", + "We do not sell your data, and we do not use advertising networks or third-party tracking SDKs.", + ], + }, + ], + }, + { + title: "2. Who is responsible", + blocks: [ + { + type: "contact", + text: "Rocksky is operated as an independent project. For any privacy question, or to exercise any right described below, contact:", + email: "support@rocksky.app", + }, + ], + }, + { + title: "3. Information you give us", + blocks: [ + { + type: "text", + text: "You sign in with an existing AT Protocol identity rather than creating a password with us. From that sign-in we receive and store:", + }, + { + type: "list", + items: [ + "Your decentralized identifier (DID) and handle", + "The address of your personal data server (PDS)", + "OAuth tokens that let us read and write the records you authorize", + ], + }, + { + type: "text", + text: "We never receive your account password, and we cannot read anything in your repository that you have not granted access to.", + }, + { + type: "text", + text: "You may also give us content directly: audio files you upload, playlists, shouts, likes, profile preferences, and API keys or access tokens you create.", + }, + ], + }, + { + title: "4. Information created when you listen", + blocks: [ + { + type: "text", + text: "A scrobble is a record that you played something. Each one contains:", + }, + { + type: "list", + items: [ + "Track title, artist, album and album artist", + "Track duration and the time you played it", + "Where the play came from — the application, player or device name", + "Identifiers used to match the track to a release, such as an ISRC or MusicBrainz id", + ], + }, + { + type: "text", + text: "To fill in missing details we may look up a track with external music metadata services, including Spotify, Deezer and MusicBrainz. Those lookups send the track and artist name, never your identity.", + }, + ], + }, + { + title: "5. What is public, and what that means", + blocks: [ + { + type: "text", + text: "This is the most important thing to understand about Rocksky. Scrobbles, likes, playlists, shouts and follows are stored as records in your AT Protocol repository, and those records are public by design. They are readable by anyone, including people without a Rocksky account, and they are distributed to other services on the network.", + }, + { + type: "text", + text: "Practical consequences you should expect:", + }, + { + type: "list", + items: [ + "Your listening history, including timestamps, can be read by anyone", + "Other applications, indexes and archives may keep their own copies", + "Deleting a record removes it from your repository and from Rocksky, but we cannot delete copies that other services already collected", + "Your profile and listening activity are reachable through our public API", + ], + }, + { + type: "text", + text: "If you do not want a listen to be public, do not scrobble it. Uploaded audio files are not published this way and remain private to your account.", + }, + ], + }, + { + title: "6. Scrobbling from other apps on your phone", + blocks: [ + { + type: "text", + text: "The Rocksky Android app can scrobble music played by other applications. To do this it needs notification access, which you grant explicitly in Android settings and can withdraw at any time. The feature does nothing until you enable it.", + }, + { + type: "text", + text: "While enabled, the app observes the media sessions of other players and reads only what it needs to identify a track:", + }, + { + type: "list", + items: [ + "Title, artist, album and album artist", + "Track duration, playback position and the media id reported by the player", + "The package name of the application that is playing", + ], + }, + { + type: "text", + text: "It does not read the content of your messages, emails or any other notification unrelated to music playback. You can exclude specific applications, and excluded applications are ignored entirely.", + }, + { + type: "text", + text: "Pending scrobbles are queued on your device so that listening works offline, and your sign-in credentials are encrypted on the device. The queue is uploaded when a connection is available and then cleared.", + }, + ], + }, + { + title: "7. Services you choose to connect", + blocks: [ + { + type: "text", + text: "You can connect third-party accounts to import a library or scrobble from them. These connections are optional and each one is separate:", + }, + { + type: "list", + items: [ + "Spotify — to scrobble playback and read track metadata", + "Dropbox and Google Drive — to read audio files you select for your library", + ], + }, + { + type: "text", + text: "We store the access tokens needed to act on your behalf for as long as the connection exists, and we request the narrowest access the feature allows. Data obtained from Google APIs is used only to provide the feature you connected it for, and is not transferred to anyone else except as needed to operate the service, with your consent, or where the law requires it. Disconnecting a service deletes the stored tokens, and you can also revoke access from that provider's own settings.", + }, + ], + }, + { + title: "8. Using your own storage", + blocks: [ + { + type: "text", + text: "Rocksky lets you attach your own S3-compatible storage bucket instead of our managed storage. If you do, we store the endpoint, region, bucket name and credentials you provide so that we can read and write your audio files there. Your files stay in your bucket and under your control, and removing the provider deletes the stored credentials.", + }, + ], + }, + { + title: "9. Technical and operational information", + blocks: [ + { + type: "text", + text: "Like any service on the internet, our servers record information needed to run and protect the platform:", + }, + { + type: "list", + items: [ + "IP address, approximate region and user agent", + "Request logs, error reports and performance traces", + "Device and player names reported by clients you have signed in", + ], + }, + { + type: "text", + text: "Listening statistics shown in the app — your charts, top artists and history — are computed by us from your own scrobbles. We do not embed advertising networks or third-party analytics or tracking SDKs in our applications.", + }, + ], + }, + { + title: "10. How we use this information", + blocks: [ + { type: "text", text: "We use the information above to:" }, + { + type: "list", + items: [ + "Provide scrobbling, your library, playback and your listening history", + "Match what you play to the correct track, album and artist", + "Produce your statistics, charts and recommendations", + "Operate, debug, secure and improve the service", + "Detect automated or fraudulent scrobbling that distorts charts for everyone", + "Comply with legal obligations and respond to valid legal requests", + ], + }, + { + type: "text", + text: "Accounts flagged by our automated abuse detection may be excluded from public charts. If you believe your account was flagged in error, contact us and we will review it.", + }, + ], + }, + { + title: "11. Who we share it with", + blocks: [ + { + type: "text", + text: "We do not sell your personal information, and we do not share it for advertising. Information reaches others only in these ways:", + }, + { + type: "list", + items: [ + "Publicly, through the AT Protocol records described in section 5", + "Infrastructure providers that host, store and deliver the service on our behalf", + "Music metadata services, for the anonymous track lookups described in section 4", + "Services you explicitly connected, as described in section 7", + "When required by law, or to protect the rights and safety of users and the platform", + ], + }, + ], + }, + { + title: "12. How long we keep it", + blocks: [ + { + type: "list", + items: [ + "Account data, scrobbles and uploads: while your account exists, until you delete them", + "OAuth tokens and storage credentials: until you disconnect that service", + "Logs and performance traces: a short operational window, then deleted or aggregated", + "On-device scrobble queues: until successfully uploaded", + ], + }, + { + type: "text", + text: "Public records already distributed across the AT Protocol network are outside our control once published. See section 5.", + }, + ], + }, + { + title: "13. Your rights and choices", + blocks: [ + { type: "text", text: "You can, at any time:" }, + { + type: "list", + items: [ + "Turn scrobbling off, or exclude individual applications from it", + "Withdraw notification access from the mobile app in Android settings", + "Disconnect any third-party service and remove your storage provider", + "Delete individual scrobbles, uploads, playlists and shouts", + "Revoke API keys and access tokens you created", + "Request a copy of the data we hold about you, or ask us to correct or delete it", + "Delete your account, which removes the data we hold for it", + ], + }, + { + type: "text", + text: "Depending on where you live you may also have the right to object to or restrict certain processing, and to complain to your local data protection authority. Because your records live in your own AT Protocol repository, you can additionally manage or move them independently of Rocksky.", + }, + ], + }, + { + title: "14. Security", + blocks: [ + { + type: "text", + text: "We use encryption in transit, encrypt stored credentials, and limit internal access to what is needed to operate the service. No service can promise perfect security, and you are responsible for keeping your own device and identity credentials safe.", + }, + ], + }, + { + title: "15. International transfers", + blocks: [ + { + type: "text", + text: "Rocksky runs on infrastructure located in several countries, and the AT Protocol network is global by nature. Using the service involves transferring information across borders, including to countries whose data protection laws differ from your own.", + }, + ], + }, + { + title: "16. Children", + blocks: [ + { + type: "text", + text: "Rocksky is not directed to children and is not intended for use by anyone under 13, or under the minimum age of digital consent where they live if that age is higher. We do not knowingly collect information from them. If you believe a child has provided us information, contact us and we will delete it.", + }, + ], + }, + { + title: "17. Changes to this policy", + blocks: [ + { + type: "text", + text: "We may update this policy as the service changes. The date above always reflects the current version, and we will give notice in the app for changes that materially affect how we handle your information.", + }, + ], + }, + { + title: "18. Contact", + blocks: [ + { + type: "contact", + text: "For privacy requests, data deletion, or any question about this policy, contact:", + email: "support@rocksky.app", + }, + ], + }, +]; + +export const PRIVACY: LegalDocument = { + title: "Privacy Policy", + lastUpdated: "October 5, 2026", + intro: + "This policy explains what information Rocksky collects, why, and what you can do about it. Rocksky is built on the AT Protocol, which makes it different from most music services in one important way: much of what you create is published as public data under your own identity. Section 5 explains exactly what that means.", + sections, +}; diff --git a/apps/shared/tos.ts b/apps/shared/tos.ts index 24350c27..50769304 100644 --- a/apps/shared/tos.ts +++ b/apps/shared/tos.ts @@ -1,16 +1,6 @@ -export type TosBlock = - | { type: "text"; text: string } - | { type: "list"; items: string[] } - | { type: "contact"; text: string; email: string }; +import type { LegalDocument, LegalSection } from "./legal"; -export type TosSection = { title: string; blocks: TosBlock[] }; - -export const TOS_LAST_UPDATED = "May 28, 2026"; - -export const TOS_INTRO = - 'Welcome to Rocksky. By accessing or using Rocksky, you agree to these Terms of Service ("Terms"). If you do not agree to these Terms, please do not use the service.'; - -export const TOS_SECTIONS: TosSection[] = [ +const sections: LegalSection[] = [ { title: "1. About Rocksky", blocks: [ @@ -173,7 +163,7 @@ export const TOS_SECTIONS: TosSection[] = [ }, { type: "text", - text: "While Rocksky may use third-party infrastructure providers, users remain responsible for the content they upload. Data handling is described in these Terms; there is no separate privacy policy document.", + text: "While Rocksky may use third-party infrastructure providers, users remain responsible for the content they upload. Please refer to the Privacy Policy for more information.", }, ], }, @@ -261,3 +251,11 @@ export const TOS_SECTIONS: TosSection[] = [ ], }, ]; + +export const TOS: LegalDocument = { + title: "Terms of Service", + lastUpdated: "May 28, 2026", + intro: + 'Welcome to Rocksky. By accessing or using Rocksky, you agree to these Terms of Service ("Terms"). If you do not agree to these Terms, please do not use the service.', + sections, +}; diff --git a/apps/web-mobile/src/App.tsx b/apps/web-mobile/src/App.tsx index 44f056da..fa7bd553 100644 --- a/apps/web-mobile/src/App.tsx +++ b/apps/web-mobile/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import MiniPlayer from "./components/MiniPlayer"; import AccessTokensPage from "./pages/access-tokens"; import AlbumPage from "./pages/album"; @@ -19,6 +19,7 @@ import NotificationsPage from "./pages/notifications"; import ProfilePage from "./pages/profile"; import Recommendations from "./pages/recommendations"; import Search from "./pages/search"; +import PrivacyPage from "./pages/privacy"; import SongPage from "./pages/song"; import TosPage from "./pages/tos"; import ShoutEditor from "./pages/shout-editor"; @@ -55,8 +56,7 @@ function App() { } /> } /> } /> - {/* There is no separate privacy policy; the terms cover data handling. */} - } /> + } /> diff --git a/apps/web-mobile/src/pages/legal/LegalDocumentView.tsx b/apps/web-mobile/src/pages/legal/LegalDocumentView.tsx new file mode 100644 index 00000000..c7f89be7 --- /dev/null +++ b/apps/web-mobile/src/pages/legal/LegalDocumentView.tsx @@ -0,0 +1,82 @@ +import type { LegalBlock, LegalDocument } from "../../../../shared/legal"; +import Main from "../../layouts/Main"; + +function Block({ block }: { block: LegalBlock }) { + if (block.type === "list") { + return ( +
    + {block.items.map((item) => ( +
  • {item}
  • + ))} +
+ ); + } + + if (block.type === "contact") { + return ( +

+ {block.text}{" "} + + {block.email} + +

+ ); + } + + return

{block.text}

; +} + +export default function LegalDocumentView({ + document, +}: { + document: LegalDocument; +}) { + return ( +
+
+

+ {document.title} +

+

+ Last updated: {document.lastUpdated} +

+ +

+ {document.intro} +

+ + {document.sections.map((section) => ( +
+

+ {section.title} +

+
+ {section.blocks.map((block, i) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web-mobile/src/pages/legal/index.tsx b/apps/web-mobile/src/pages/legal/index.tsx new file mode 100644 index 00000000..d9359305 --- /dev/null +++ b/apps/web-mobile/src/pages/legal/index.tsx @@ -0,0 +1,3 @@ +import LegalDocumentView from "./LegalDocumentView"; + +export default LegalDocumentView; diff --git a/apps/web-mobile/src/pages/privacy/index.tsx b/apps/web-mobile/src/pages/privacy/index.tsx new file mode 100644 index 00000000..901e9bee --- /dev/null +++ b/apps/web-mobile/src/pages/privacy/index.tsx @@ -0,0 +1,6 @@ +import { PRIVACY } from "../../../../shared/privacy"; +import LegalDocumentView from "../legal"; + +export default function PrivacyPage() { + return ; +} diff --git a/apps/web-mobile/src/pages/tos/index.tsx b/apps/web-mobile/src/pages/tos/index.tsx index adf12dee..e9fe7e0f 100644 --- a/apps/web-mobile/src/pages/tos/index.tsx +++ b/apps/web-mobile/src/pages/tos/index.tsx @@ -1,83 +1,6 @@ -import { - TOS_INTRO, - TOS_LAST_UPDATED, - TOS_SECTIONS, - type TosBlock, -} from "../../../../shared/tos"; -import Main from "../../layouts/Main"; - -function Block({ block }: { block: TosBlock }) { - if (block.type === "list") { - return ( -
    - {block.items.map((item) => ( -
  • {item}
  • - ))} -
- ); - } - - if (block.type === "contact") { - return ( -

- {block.text}{" "} - - {block.email} - -

- ); - } - - return

{block.text}

; -} +import { TOS } from "../../../../shared/tos"; +import LegalDocumentView from "../legal"; export default function TosPage() { - return ( -
-
-

- Terms of Service -

-

- Last updated: {TOS_LAST_UPDATED} -

- -

- {TOS_INTRO} -

- - {TOS_SECTIONS.map((section) => ( -
-

- {section.title} -

-
- {section.blocks.map((block, i) => ( - - ))} -
-
- ))} -
-
- ); + return ; } diff --git a/apps/web/src/layouts/Links.tsx b/apps/web/src/layouts/Links.tsx index 7e77121e..3ace214a 100644 --- a/apps/web/src/layouts/Links.tsx +++ b/apps/web/src/layouts/Links.tsx @@ -71,6 +71,12 @@ function Links() { Terms + + Privacy + ); } diff --git a/apps/web/src/pages/legal/LegalDocumentView.tsx b/apps/web/src/pages/legal/LegalDocumentView.tsx new file mode 100644 index 00000000..80769815 --- /dev/null +++ b/apps/web/src/pages/legal/LegalDocumentView.tsx @@ -0,0 +1,67 @@ +import type { + LegalBlock, + LegalDocument, +} from "../../../../shared/legal"; +import Main from "../../layouts/Main"; + +function Block({ block }: { block: LegalBlock }) { + if (block.type === "list") { + return ( +
    + {block.items.map((item) => ( +
  • {item}
  • + ))} +
+ ); + } + + if (block.type === "contact") { + return ( +

+ {block.text}{" "} + + {block.email} + +

+ ); + } + + return

{block.text}

; +} + +function LegalDocumentView({ document }: { document: LegalDocument }) { + return ( +
+
+

+ {document.title} +

+

+ Last updated: {document.lastUpdated} +

+ +

+ {document.intro} +

+ + {document.sections.map((section) => ( +
+

+ {section.title} +

+
+ {section.blocks.map((block, i) => ( + + ))} +
+
+ ))} +
+
+ ); +} + +export default LegalDocumentView; diff --git a/apps/web/src/pages/legal/index.tsx b/apps/web/src/pages/legal/index.tsx new file mode 100644 index 00000000..d9359305 --- /dev/null +++ b/apps/web/src/pages/legal/index.tsx @@ -0,0 +1,3 @@ +import LegalDocumentView from "./LegalDocumentView"; + +export default LegalDocumentView; diff --git a/apps/web/src/pages/privacy/Privacy.tsx b/apps/web/src/pages/privacy/Privacy.tsx new file mode 100644 index 00000000..b52a72bf --- /dev/null +++ b/apps/web/src/pages/privacy/Privacy.tsx @@ -0,0 +1,8 @@ +import { PRIVACY } from "../../../../shared/privacy"; +import LegalDocumentView from "../legal"; + +function Privacy() { + return ; +} + +export default Privacy; diff --git a/apps/web/src/pages/privacy/index.tsx b/apps/web/src/pages/privacy/index.tsx new file mode 100644 index 00000000..126fc177 --- /dev/null +++ b/apps/web/src/pages/privacy/index.tsx @@ -0,0 +1,3 @@ +import Privacy from "./Privacy"; + +export default Privacy; diff --git a/apps/web/src/pages/tos/Tos.tsx b/apps/web/src/pages/tos/Tos.tsx index 5d6bec02..8492e51e 100644 --- a/apps/web/src/pages/tos/Tos.tsx +++ b/apps/web/src/pages/tos/Tos.tsx @@ -1,69 +1,8 @@ -import { - TOS_INTRO, - TOS_LAST_UPDATED, - TOS_SECTIONS, - type TosBlock, -} from "../../../../shared/tos"; -import Main from "../../layouts/Main"; - -function Block({ block }: { block: TosBlock }) { - if (block.type === "list") { - return ( -
    - {block.items.map((item) => ( -
  • {item}
  • - ))} -
- ); - } - - if (block.type === "contact") { - return ( -

- {block.text}{" "} - - {block.email} - -

- ); - } - - return

{block.text}

; -} +import { TOS } from "../../../../shared/tos"; +import LegalDocumentView from "../legal"; function Tos() { - return ( -
-
-

- Terms of Service -

-

- Last updated: {TOS_LAST_UPDATED} -

- -

- {TOS_INTRO} -

- - {TOS_SECTIONS.map((section) => ( -
-

- {section.title} -

-
- {section.blocks.map((block, i) => ( - - ))} -
-
- ))} -
-
- ); + return ; } export default Tos; diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx index 15aa267a..59fff919 100644 --- a/apps/web/src/routes/privacy.tsx +++ b/apps/web/src/routes/privacy.tsx @@ -1,8 +1,6 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import PrivacyPage from "../pages/privacy"; -// There is no separate privacy policy; the terms cover data handling. export const Route = createFileRoute("/privacy")({ - beforeLoad: () => { - throw redirect({ to: "/tos", replace: true }); - }, + component: PrivacyPage, });