diff --git a/CHANGELOG.md b/CHANGELOG.md
index b71776ee7..5e193f165 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`GET/PUT /api/guilds/:guildId/rbac` and `GET /api/guilds/:id/me`
- Added frontend Access Control section in Server Settings to manage module
grants by Discord role as full-policy replacement
+- Added public legal routes for Discord app metadata:
+ `/terms-of-service`, `/privacy-policy` with aliases `/terms`, `/privacy`
+- Added public install redirect endpoint (`/api/install`) and canonical install
+ link (`https://lucky.lucassantana.tech/install`) for Discord app installation
+- Added Discord Activities URL mapping policy for embedded app setup:
+ root prefix `/` targets `lucky.lucassantana.tech` with no proxy path mappings
### Fixed
@@ -43,6 +49,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Frontend shell now initializes guild selection on all authenticated routes,
so the server selector is populated right after login instead of only after
visiting pages that manually triggered guild loading (PR #162)
+- Vercel deep links now use a final SPA fallback rewrite after `/api` and
+ `/install`, and README now documents the complete Discord portal URL mapping
+ (General Information, Installation, and Activities URL Mappings)
+- `/install` now proxies to `/api/auth/discord` so the public install URL
+ reliably returns Discord OAuth redirect (`302`) on production
### Changed
diff --git a/README.md b/README.md
index 9016f6fd3..fde5e3604 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,22 @@ Set `WEBAPP_EXPECTED_CLIENT_ID` to the production Discord app id to make
Set `WEBAPP_BACKEND_URL` to your public backend/API origin when you expose API routes
through a dedicated host.
+Discord Developer Portal URL mapping for this deployment:
+
+- General Information:
+ - Interaction Endpoint URL: leave empty
+ - Linked Roles Verification URL: leave empty
+ - Terms of Service URL: `https://lucky.lucassantana.tech/terms-of-service`
+ - Privacy Policy URL: `https://lucky.lucassantana.tech/privacy-policy`
+- Installation:
+ - Installation Link (custom): `https://lucky.lucassantana.tech/install`
+ - Install redirect target: `/api/auth/discord`
+ - Install contexts: Guild Install enabled, User Install disabled
+- Activities -> URL Mappings:
+ - Root mapping prefix: `/`
+ - Root mapping target: `lucky.lucassantana.tech`
+ - Proxy path mappings: none (leave empty)
+
## Environment Variables
See `.env.example` for all available options. Key variables:
diff --git a/packages/frontend/src/App.legalRoutes.test.tsx b/packages/frontend/src/App.legalRoutes.test.tsx
new file mode 100644
index 000000000..a97ba3e96
--- /dev/null
+++ b/packages/frontend/src/App.legalRoutes.test.tsx
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import App from './App'
+import { useAuthStore } from '@/stores/authStore'
+
+vi.mock('@/stores/authStore')
+
+describe('App legal routes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ vi.mocked(useAuthStore).mockImplementation((selector) => {
+ const store = {
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ isDeveloper: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ checkAuth: vi.fn().mockResolvedValue(undefined),
+ checkDeveloperStatus: vi.fn(),
+ }
+
+ return selector ? selector(store) : store
+ })
+ })
+
+ function renderAt(path: string) {
+ return render(
+
+
+ ,
+ )
+ }
+
+ test('renders terms page for canonical path while unauthenticated', async () => {
+ renderAt('/terms-of-service')
+
+ expect(
+ await screen.findByRole('heading', { name: /terms of service/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: /login with discord/i }),
+ ).not.toBeInTheDocument()
+ })
+
+ test('renders privacy page for canonical path while unauthenticated', async () => {
+ renderAt('/privacy-policy')
+
+ expect(
+ await screen.findByRole('heading', { name: /privacy policy/i }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: /login with discord/i }),
+ ).not.toBeInTheDocument()
+ })
+
+ test('renders terms page for alias path', async () => {
+ renderAt('/terms')
+
+ expect(
+ await screen.findByRole('heading', { name: /terms of service/i }),
+ ).toBeInTheDocument()
+ })
+
+ test('renders privacy page for alias path', async () => {
+ renderAt('/privacy')
+
+ expect(
+ await screen.findByRole('heading', { name: /privacy policy/i }),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index b6e708f0b..e9c41eb48 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from 'react'
-import { Routes, Route, Navigate } from 'react-router-dom'
+import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { ShieldAlert } from 'lucide-react'
import ErrorBoundary from '@/components/ErrorBoundary'
import { useAuthStore } from './stores/authStore'
@@ -35,6 +35,19 @@ const TwitchNotificationsPage = lazy(
() => import('./pages/TwitchNotifications'),
)
const LastFmPage = lazy(() => import('./pages/LastFm'))
+const TermsOfServicePage = lazy(() => import('./pages/TermsOfService'))
+const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicy'))
+
+const LEGAL_PATHS = [
+ '/terms-of-service',
+ '/terms',
+ '/privacy-policy',
+ '/privacy',
+]
+
+function isLegalPath(pathname: string) {
+ return LEGAL_PATHS.includes(pathname)
+}
function ForbiddenModulePage({ module }: { module: ModuleKey }) {
return (
@@ -150,7 +163,23 @@ function AuthenticatedRoutes() {
)
}
+function LegalRoutes() {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+
+ )
+}
+
function App() {
+ const location = useLocation()
const { isAuthenticated, isLoading, checkAuth } = useAuthStore()
const [isReady, setIsReady] = useState(false)
const initialized = useRef(false)
@@ -164,6 +193,18 @@ function App() {
.catch(() => setIsReady(true))
}, [checkAuth])
+ if (isLegalPath(location.pathname)) {
+ return (
+
+
+ }>
+
+
+
+
+ )
+ }
+
// Show loader while initializing
if (!isReady || isLoading) {
return (
diff --git a/packages/frontend/src/pages/PrivacyPolicy.tsx b/packages/frontend/src/pages/PrivacyPolicy.tsx
new file mode 100644
index 000000000..2de1b3573
--- /dev/null
+++ b/packages/frontend/src/pages/PrivacyPolicy.tsx
@@ -0,0 +1,58 @@
+export default function PrivacyPolicyPage() {
+ return (
+
+
+
+
+
+ Data we collect
+
+ Lucky processes Discord account identifiers and server
+ configuration data required to provide bot features and
+ dashboard access.
+
+
+
+
+ How we use data
+
+ Data is used to authenticate users, manage server
+ settings, and operate integrations requested by server
+ administrators.
+
+
+
+
+ Data sharing and retention
+
+ Lucky does not sell personal data. Data is retained only
+ as needed for service operation, security, and legal
+ obligations.
+
+
+
+
+
+
+ )
+}
diff --git a/packages/frontend/src/pages/TermsOfService.tsx b/packages/frontend/src/pages/TermsOfService.tsx
new file mode 100644
index 000000000..c197bc1f6
--- /dev/null
+++ b/packages/frontend/src/pages/TermsOfService.tsx
@@ -0,0 +1,59 @@
+export default function TermsOfServicePage() {
+ return (
+
+
+
+
+
+ Service scope
+
+ Lucky provides Discord bot features and a dashboard for
+ server management. By using Lucky, you agree to these
+ terms.
+
+
+
+
+ Acceptable use
+
+ You must use Lucky in compliance with Discord terms,
+ applicable laws, and your server rules. Abuse, misuse,
+ or attempts to disrupt the service are prohibited.
+
+
+
+
+ Availability and changes
+
+ Lucky is provided on an as-is basis. Features may be
+ updated, limited, or removed to improve reliability,
+ security, and compliance.
+
+
+
+
+
+
+ )
+}
diff --git a/vercel.json b/vercel.json
index 9bb139bf1..069d18751 100644
--- a/vercel.json
+++ b/vercel.json
@@ -7,6 +7,14 @@
{
"source": "/api/:path*",
"destination": "https://lucky-api.lucassantana.tech/api/:path*"
+ },
+ {
+ "source": "/install",
+ "destination": "https://lucky-api.lucassantana.tech/api/auth/discord"
+ },
+ {
+ "source": "/:path*",
+ "destination": "/index.html"
}
]
}