Skip to content

Discord Verification Frontend Flow

See also: Discord Integration Configuration for backend configuration and setup.

Overview

This guide describes how the Discord verification frontend flow works in the shipped implementation.

There is no dedicated SvelteKit verification route. The verification page itself is HTML served by the backend (FastEndpoints), and the only piece that lives in the SvelteKit app (src/Apps/foundry-web) is the login page, which honours a redirect query parameter after a successful login. The two pieces are wired together through the BallrUrl and Discord:Verification:BaseUrl configuration keys.

The pieces involved are:

Verification Flow

sequenceDiagram
    participant User
    participant Discord
    participant VerifyPage as Backend /discord/verify
    participant Login as SvelteKit /login
    participant Complete as Backend /api/discord/verify/complete

    User->>Discord: /verify command
    Discord->>User: Embed with "Verify" button linking to {Discord:Verification:BaseUrl}/discord/verify?token=...
    User->>VerifyPage: Open verification link
    VerifyPage->>VerifyPage: Validate HMAC token (DiscordTokenService)
    VerifyPage->>Complete: POST token (checkAuthentication on page load)
    Complete-->>VerifyPage: 401 if not logged in
    VerifyPage->>User: Show "Login to Ballr" button → {BallrUrl}/login?redirect=...
    User->>Login: Open login page with redirect param
    User->>Login: Submit credentials (authStore.login)
    Login->>User: goto(redirectUrl) → back to {BallrUrl}/discord/verify?token=...
    User->>VerifyPage: Reopen verification page (now authenticated)
    VerifyPage->>Complete: POST token (now carries auth)
    Complete->>Complete: Link Discord account + assign verified role
    Complete-->>User: success → page shows confirmation and closes

Backend Verification Page

The verification page is rendered entirely by the backend. DiscordVerificationPageEndpoint registers the anonymous route GET /discord/verify, validates the HMAC token via DiscordTokenService, and returns an HTML page (or an error page if the token is invalid or expired).

The page does two things in the browser:

  1. On load it calls POST /api/discord/verify/complete with the token. If the response is 401 the user is not authenticated, so the "Login to Ballr" button is shown. If the user is already authenticated the call links the account immediately and shows a success message.
  2. The "Login to Ballr" button links to the SvelteKit login page with a redirect parameter pointing back at the verification page:
{BallrUrl}/login?redirect={url-encoded {BallrUrl}/discord/verify?token=...}

This link is built in GenerateVerificationPage, using the BallrUrl configuration key (default https://ballr.live) and Uri.EscapeDataString for the redirect value.

The completion endpoint, CompleteDiscordVerificationEndpoint, requires authentication (AuthorizationPolicyDefinition.RequireMember, i.e. a Clerk JWT). It validates the token, resolves the current user, performs an idempotency check via DiscordUserLookupQuery, executes LinkDiscordAccountCommand, and assigns the verified Discord role.

SvelteKit Login Page

The login page is the only frontend component in this flow. It lives at src/Apps/foundry-web/src/routes/login/+page.svelte.

It reads the redirect query parameter, defaulting to /:

import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';

// Get redirect URL from query params
const redirectUrl = $derived($page.url.searchParams.get('redirect') || '/');

If the user is already authenticated when the page mounts, it navigates straight to the redirect target:

import { onMount } from 'svelte';

onMount(() => {
    if (authStore.isAuthenticated) {
        goto(redirectUrl);
    }
});

After a successful login it does the same:

const result = await authStore.login(email, password);

if (result.success) {
    // Redirect to the original page or home
    goto(redirectUrl);
} else {
    error = result.error || 'Login failed. Please try again.';
    isLoading = false;
}

The redirect parameter is a generic "return to where you came from" mechanism shared with the rest of the app: the root layout src/Apps/foundry-web/src/routes/+layout.svelte sends unauthenticated users on protected routes to /login?redirect=<currentPath>. The Discord verification flow reuses the same parameter; the only difference is that the redirect target is the backend-served /discord/verify?token=... page rather than an in-app route.

The login page does not special-case Discord. It does not display a Discord-specific notice, and it does not contain any redirect-domain allowlist. The redirect value sent by the verification page is a same-origin path under BallrUrl, so goto returns the now-authenticated browser to the backend verification page, where the on-load POST /api/discord/verify/complete call now carries the user's session and completes the link.

Configuration

The flow relies on two configuration keys:

Configuration Key Used by Purpose
BallrUrl DiscordVerificationPageEndpoint (GenerateVerificationPage) Base URL of the SvelteKit app; used to build the /login?redirect=... link. Defaults to https://ballr.live when unset.
Discord:Verification:BaseUrl InitiateDiscordVerificationEndpoint and DiscordBotService Base URL used to build the /discord/verify?token=... link delivered to the user.

In local development, the Aspire host sets both keys. BallrUrl is read from configuration (http://localhost:3000 in appsettings.json) and Discord__Verification__BaseUrl is wired to the API's https endpoint. See ServiceCollectionExtension.

For the full set of Discord configuration keys (bot token, HMAC secret, token expiry, etc.) see Discord Integration Configuration.

Testing the Flow

End-to-end (local)

  1. Start the backend stack via the Aspire host.
  2. Start the SvelteKit app (pnpm web:dev), reachable at http://localhost:3000 (matching BallrUrl).
  3. Use the Discord bot /verify command to receive a verification link.
  4. Open the link. The backend serves the verification page; because you are not yet authenticated, the "Login to Ballr" button is shown.
  5. Click it. You land on /login?redirect=.... Sign in.
  6. After login, goto returns you to the verification page, which completes the link and assigns the verified role.

Manual login-redirect check

You can exercise the login redirect directly by visiting a URL with a redirect parameter, for example:

http://localhost:3000/login?redirect=%2Fdiscord%2Fverify%3Ftoken%3Dtest123

After a successful login the page navigates to the decoded redirect target.

Troubleshooting

  • Verification page shows "Invalid or expired verification link" — the HMAC token failed validation in DiscordTokenService (wrong Discord:Verification:HmacSecret, tampered token, or expired). Request a fresh link.
  • "Login to Ballr" link points at the wrong host — check BallrUrl; it is the base of the /login link generated by the verification page.
  • Login succeeds but verification does not complete — confirm the redirect target resolves back to {BallrUrl}/discord/verify?token=... on the same origin, and that the browser session carries the Clerk JWT required by POST /api/discord/verify/complete.