1/** 2 * Top Down Tabletop Auth Bridge for Throne Room 3 * 4 * Provides optional Top Down Tabletop authentication for standalone tournament pages. 5 * If a player is logged into Top Down Tabletop, their identity (display name, avatar, 6 * player ID) can be used to pre-fill registration and link tournament results 7 * to their Top Down Tabletop profile. If not logged in, tournament pages fall back 8 * to their existing anonymous name-entry flow. 9 * 10 * Usage: 11 * <script src="auth-bridge.js"></script> 12 * const user = window.BFAuth.getBFUser(); 13 * if (user) { nameInput.value = user.displayName; } 14 * 15 * All state is stored in localStorage under the same keys the Top Down Tabletop 16 * desktop client uses, so a login from either context is shared. 17 */ 18(function () { 19 'use strict'; 20 21 // --------------------------------------------------------------------------- 22 // Configuration â matches apps/desktop/src/stores/profileStore.ts 23 // --------------------------------------------------------------------------- 24 25 var TOKEN_KEY = 'dta_authToken'; 26 var PLAYER_ID_KEY = 'dta_playerId'; 27 28 // Default server URL. Override by setting window.BF_SERVER_URL before loading 29 // this script, or by adding a <meta name="bf-server-url" content="..."> tag. 30 var SERVER_URL = (function () { 31 if (typeof window.BF_SERVER_URL === 'string' && window.BF_SERVER_URL) { 32 return window.BF_SERVER_URL.replace(/\/+$/, ''); 33 } 34 var meta = document.querySelector('meta[name="bf-server-url"]'); 35 if (meta && meta.content) { 36 return meta.content.replace(/\/+$/, ''); 37 } 38 return 'https://topdowntabletop.com'; 39 })(); 40 41 // --------------------------------------------------------------------------- 42 // Internal helpers 43 // --------------------------------------------------------------------------- 44 45 /** Decode a JWT payload without verification (client-side convenience only). */ 46 function decodeJwtPayload(token) { 47 try { 48 var parts = token.split('.'); 49 if (parts.length !== 3) return null; 50 // Base64url -> Base64 51 var base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); 52 var json = decodeURIComponent( 53 atob(base64) 54 .split('') 55 .map(function (c) { 56 return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 57 }) 58 .join('') 59 ); 60 return JSON.parse(json); 61 } catch (e) { 62 return null; 63 } 64 } 65 66 /** Check whether a JWT has expired (with 60s grace). */ 67 function isTokenExpired(token) { 68 var payload = decodeJwtPayload(token); 69 if (!payload || !payload.exp) return true; 70 return payload.exp * 1000 < Date.now() - 60000; 71 } 72 73 /** 74 * Resolve an avatar URL from the server. Server stores relative paths like 75 * "/uploads/avatars/abc.png" which need the server origin prepended. 76 */ 77 function resolveAvatarUrl(url) { 78 if (!url) return null; 79 if ( 80 url.startsWith('http://') || 81 url.startsWith('https://') || 82 url.startsWith('data:') 83 ) { 84 return url; 85 } 86 return SERVER_URL + (url.startsWith('/') ? '' : '/') + url; 87 } 88 89 // --------------------------------------------------------------------------- 90 // Cached user object â populated lazily 91 // --------------------------------------------------------------------------- 92 93 var _cachedUser = null; 94 var _cacheToken = null; // token string that produced the cache 95 96 function buildUserFromToken() { 97 var token = localStorage.getItem(TOKEN_KEY); 98 if (!token) { 99 _cachedUser = null; 100 _cacheToken = null; 101 return null; 102 } 103 // Return cache if the token hasn't changed 104 if (token === _cacheToken && _cachedUser !== null) return _cachedUser; 105 106 if (isTokenExpired(token)) { 107 // Token expired â clear it so the player can log in fresh 108 localStorage.removeItem(TOKEN_KEY); 109 localStorage.removeItem(PLAYER_ID_KEY); 110 _cachedUser = null; 111 _cacheToken = null; 112 return null; 113 } 114 115 var payload = decodeJwtPayload(token); 116 if (!payload || !payload.playerId) { 117 _cachedUser = null; 118 _cacheToken = null; 119 return null; 120 } 121 122 // The JWT only carries playerId and email. Display name and avatar must be 123 // fetched from /api/auth/me. We store a minimal object now and enrich it 124 // asynchronously when fetchProfile() is called. 125 _cachedUser = { 126 id: payload.playerId, 127 email: payload.email || null, 128 displayName: null, // populated by fetchProfile 129 avatarUrl: null, // populated by fetchProfile 130 tier: 'free', 131 role: null, 132 }; 133 _cacheToken = token; 134 return _cachedUser; 135 } 136 137 // --------------------------------------------------------------------------- 138 // Network helpers 139 // --------------------------------------------------------------------------- 140 141 function authHeaders() { 142 var token = localStorage.getItem(TOKEN_KEY); 143 var headers = { 'Content-Type': 'application/json' }; 144 if (token) headers['Authorization'] = 'Bearer ' + token; 145 return headers; 146 } 147 148 /** 149 * T12 (2026-09-27 security hardening): the central hook every authFetch() 150 * 403 is checked against. T11 gates every data-reading route behind 151 * requireTos, so a signed-in caller whose accepted Terms are stale gets 152 * back 403 { error: 'Terms acceptance required', code: 'TOS_REQUIRED' } 153 * instead of the data it asked for. auth-bridge.js is loaded on BOTH web 154 * surfaces (/m and /app, which loads it as /m/auth-bridge.js), so this is 155 * the one place that can react to that response for every caller of 156 * authFetch() on either surface, no matter which page or module made the 157 * request. Each surface installs its own opener: bf-legal.js's 158 * handleTosError() re-opens the phone re-acceptance modal; 159 * app/terms-gate.js's open() re-opens the desktop-shell modal. Neither 160 * script is guaranteed to have loaded yet when this fires (best-effort, 161 * wrapped so a missing/throwing hook never breaks the caller's own 162 * response handling). 163 * 164 * Fix round 1 (2026-09-27 review): reads the body itself and only calls 165 * the surface's opener when it actually matches the TOS_REQUIRED contract 166 * -- mirroring the desktop lane's authFetch() (peek the body, filter, 167 * THEN notify). Any OTHER 403 on this app (an unrelated Forbidden from a 168 * shop/staff/admin route, a rate limit, ...) must never reach the opener 169 * at all -- it previously called handleTosError() unconditionally on 170 * every 403 and relied entirely on that function's own filtering, which 171 * worked but meant a body read + hook call on every unrelated 403 too. 172 * `response` here is already a clone() (see authFetch() below), so 173 * reading its body never touches the ORIGINAL response the caller reads. 174 */ 175 function notifyTosRequired(response) { 176 response 177 .json() 178 .then(function (body) { 179 if (!body || body.code !== 'TOS_REQUIRED') return; 180 try { 181 if (window.BFLegal && typeof window.BFLegal.handleTosError === 'function') { 182 window.BFLegal.handleTosError(body); 183 return; 184 } 185 if (window.TDTTTermsGate && typeof window.TDTTTermsGate.open === 'function') { 186 window.TDTTTermsGate.open(); 187 } 188 } catch (e) { 189 /* best-effort notification only -- never let this break the caller */ 190 } 191 }) 192 .catch(function () { 193 /* non-JSON/empty 403 body -- can't be a TOS_REQUIRED response */ 194 }); 195 } 196 197 /** 198 * fetch() wrapper that automatically attaches the Bearer token (if a valid, 199 * non-expired one exists). Use this for any gated endpoint so already 200 * logged-in users get zero-friction access. Anonymous callers still send the 201 * request (no Authorization header) so public endpoints keep working and the 202 * caller can react to a 401. 203 * 204 * @param {string} url 205 * @param {RequestInit} [opts] 206 * @returns {Promise<Response>} 207 */ 208 function authFetch(url, opts) { 209 opts = opts || {}; 210 var token = getToken(); 211 if (token) { 212 var headers = Object.assign({}, opts.headers || {}); 213 // Don't clobber an explicitly-provided Authorization header. 214 if (!headers.Authorization && !headers.authorization) { 215 headers.Authorization = 'Bearer ' + token; 216 } 217 opts = Object.assign({}, opts, { headers: headers }); 218 } 219 return fetch(url, opts).then(function (r) { 220 // Peeked via clone() -- the caller still gets the ORIGINAL, unconsumed 221 // response to read status/json() itself, exactly as before. 222 if (r.status === 403) notifyTosRequired(r.clone()); 223 return r; 224 }); 225 } 226 227 // --------------------------------------------------------------------------- 228 // Public API 229 // --------------------------------------------------------------------------- 230 231 /** 232 * Fetch the full player profile from the server (/api/auth/me). 233 * Enriches the cached user object with displayName, avatarUrl, tier, role. 234 * Returns the enriched user or null if not authenticated. 235 */ 236 async function fetchProfile() { 237 var token = localStorage.getItem(TOKEN_KEY); 238 if (!token) return null; 239 240 if (isTokenExpired(token)) { 241 localStorage.removeItem(TOKEN_KEY); 242 localStorage.removeItem(PLAYER_ID_KEY); 243 _cachedUser = null; 244 _cacheToken = null; 245 return null; 246 } 247 248 try { 249 var res = await fetch(SERVER_URL + '/api/auth/me', { 250 headers: { Authorization: 'Bearer ' + token }, 251 cache: 'no-store', // never trust a cached profile (stale tosVersion re-shows the ToS gate) 252 }); 253 if (!res.ok) { 254 if (res.status === 401) { 255 // Token rejected by server â clear local state 256 localStorage.removeItem(TOKEN_KEY); 257 localStorage.removeItem(PLAYER_ID_KEY); 258 _cachedUser = null; 259 _cacheToken = null; 260 } 261 return null; 262 } 263 var player = await res.json(); 264 localStorage.setItem(PLAYER_ID_KEY, player.id); 265 266 _cachedUser = { 267 id: player.id, 268 email: player.email || null,
269 displayName: player.displayName || null, 270 avatarUrl: resolveAvatarUrl(player.avatarUrl), 271 tier: player.tier || 'free', 272 role: player.role || null, 273 tosVersion: player.tosVersion || null, 274 emailVerified: !!player.emailVerified, 275 }; 276 _cacheToken = token; 277 return _cachedUser; 278 } catch (e) { 279 console.warn('[BFAuth] Failed to fetch profile:', e); 280 return null; 281 } 282 } 283 284 /** 285 * Get the current Top Down Tabletop user from the cached JWT. 286 * Returns { id, displayName, avatarUrl, email, tier, role } or null. 287 * 288 * Note: displayName and avatarUrl may be null until fetchProfile() completes. 289 * Call fetchProfile() on page load for full data. 290 */ 291 function getBFUser() { 292 return buildUserFromToken(); 293 } 294 295 /** 296 * Whether the player has a valid (non-expired) Top Down Tabletop JWT. 297 */ 298 function isLoggedIn() { 299 var token = localStorage.getItem(TOKEN_KEY); 300 if (!token) return false; 301 return !isTokenExpired(token); 302 } 303 304 /** 305 * Log in via the Top Down Tabletop /api/auth/login endpoint. 306 * On success, stores the JWT and player ID in localStorage and returns the 307 * player object. On failure, throws an Error with the server's message. 308 * 309 * @param {string} username - Top Down Tabletop display name 310 * @param {string} password - Account password 311 * @returns {Promise<{id: string, displayName: string, avatarUrl: string|null, email: string|null, role: string|null}>} 312 */ 313 async function loginWithBoardForge(username, password) { 314 var res = await fetch(SERVER_URL + '/api/auth/login', { 315 method: 'POST', 316 headers: { 'Content-Type': 'application/json' }, 317 body: JSON.stringify({ username: username, password: password }), 318 }); 319 320 var data = await res.json(); 321 322 if (!res.ok) { 323 throw new Error(data.error || 'Login failed'); 324 } 325 326 // Store credentials 327 localStorage.setItem(TOKEN_KEY, data.token); 328 localStorage.setItem(PLAYER_ID_KEY, data.player.id); 329 330 // Build cached user 331 _cachedUser = { 332 id: data.player.id, 333 email: data.player.email || null, 334 displayName: data.player.displayName || null, 335 avatarUrl: resolveAvatarUrl(data.player.avatarUrl), 336 tier: 'free', // login response doesn't include tier; fetchProfile will enrich 337 role: data.player.role || null, 338 tosVersion: data.player.tosVersion || null, 339 }; 340 _cacheToken = data.token; 341 342 return _cachedUser; 343 } 344 345 /** 346 * Register a new Top Down Tabletop account via /api/auth/register. 347 * On success, stores the JWT and returns the player object. 348 * 349 * @param {string} username - Desired display name (3-30 chars, alphanumeric/hyphen/underscore) 350 * @param {string} email - Email address 351 * @param {string} password - Password (min 6 chars) 352 * @param {boolean} [acceptedTos=false] - Whether the user agreed to the ToS/Privacy (clickwrap). Server now REQUIRES true. 353 * @param {string} [postalCode] - Optional private US ZIP; sent only when non-empty (the server validates it). 354 * @returns {Promise<{id: string, displayName: string, avatarUrl: string|null, email: string|null, role: string|null, tosVersion: string|null}>} 355 */ 356 async function registerWithBoardForge(username, email, password, acceptedTos, postalCode) { 357 var payload = { 358 username: username, 359 email: email, 360 password: password, 361 acceptedTos: acceptedTos === true, 362 }; 363 if (typeof postalCode === 'string' && postalCode.trim() !== '') payload.postalCode = postalCode.trim(); 364 var res = await fetch(SERVER_URL + '/api/auth/register', { 365 method: 'POST', 366 headers: { 'Content-Type': 'application/json' }, 367 body: JSON.stringify(payload), 368 }); 369 370 var data = await res.json(); 371 372 if (!res.ok) { 373 throw new Error(data.error || 'Registration failed'); 374 } 375 376 localStorage.setItem(TOKEN_KEY, data.token); 377 localStorage.setItem(PLAYER_ID_KEY, data.player.id); 378 379 _cachedUser = { 380 id: data.player.id, 381 email: data.player.email || null, 382 displayName: data.player.displayName || null, 383 avatarUrl: resolveAvatarUrl(data.player.avatarUrl), 384 tier: 'free', 385 role: data.player.role || null, 386 tosVersion: data.player.tosVersion || null, 387 }; 388 _cacheToken = data.token; 389 390 return _cachedUser; 391 } 392 393 /** 394 * Log out â clears the stored JWT and player ID. 395 */ 396 function logout() { 397 localStorage.removeItem(TOKEN_KEY); 398 localStorage.removeItem(PLAYER_ID_KEY); 399 _cachedUser = null; 400 _cacheToken = null; 401 } 402 403 /** 404 * Get the raw JWT token (for passing to other services that need it). 405 * Returns the token string or null. 406 */ 407 function getToken() { 408 var token = localStorage.getItem(TOKEN_KEY); 409 if (!token || isTokenExpired(token)) return null; 410 return token; 411 } 412 413 /** 414 * Get the stored player ID (available even without a network call). 415 */ 416 function getPlayerId() { 417 return localStorage.getItem(PLAYER_ID_KEY) || null; 418 } 419 420 /** 421 * Build a full avatar URL for any player ID. Useful for displaying 422 * opponent avatars in tournament match cards. 423 * 424 * @param {string} playerId - The Top Down Tabletop player ID 425 * @returns {string}
425 Full URL to the player's avatar endpoint 426 */ 427 function getBFAvatarUrl(playerId) { 428 if (!playerId) return null; 429 return SERVER_URL + '/api/players/' + playerId + '/avatar'; 430 } 431 432 /** 433 * Get the configured server URL (useful for building custom API calls). 434 */ 435 function getServerUrl() { 436 return SERVER_URL; 437 } 438 439 /** 440 * The ToS version recorded on the current user's account, if known. 441 * Comes from the login/register response or fetchProfile() (/api/auth/me). 442 * May be null until fetchProfile() has run. Returns null if not logged in. 443 */ 444 function getTosVersion() { 445 return (_cachedUser && _cachedUser.tosVersion) || null; 446 } 447 448 /** 449 * Fetch the current legal version contract: { version, effectiveDate, docs }. 450 * Public endpoint â no token required. 451 */ 452 async function getLegalVersion() { 453 try { 454 var res = await fetch(SERVER_URL + '/api/legal/version', { cache: 'no-store' }); 455 if (!res.ok) return null; 456 return await res.json(); 457 } catch (e) { 458 return null; 459 } 460 } 461 462 /** 463 * Fetch the plain-English summary shown at the signup checkbox / re-accept 464 * gate: { version, effectiveDate, points: [{title, plain, covers}] } or null. 465 * Public endpoint â no token required. 466 */ 467 async function getLegalSummary() { 468 try { 469 var res = await fetch(SERVER_URL + '/api/legal/summary', { cache: 'no-store' }); 470 if (!res.ok) return null; 471 return await res.json(); 472 } catch (e) { 473 return null; 474 } 475 } 476 477 /** 478 * Fetch a legal document fragment ("terms" | "privacy"). 479 * Returns { doc, title, version, effectiveDate, html } or null. Public. 480 */ 481 async function getLegalDoc(doc) { 482 try { 483 var res = await fetch(SERVER_URL + '/api/legal/' + encodeURIComponent(doc), { 484 cache: 'no-store', 485 }); 486 if (!res.ok) return null; 487 return await res.json(); 488 } catch (e) { 489 return null; 490 } 491 } 492 493 /** 494 * Record re-acceptance of the current Terms for the logged-in user. 495 * Requires a valid Bearer token. On success updates the cached tosVersion 496 * and returns the new version string. Throws on failure. 497 * 498 * @returns {Promise<string>} the accepted tosVersion 499 */ 500 async function acceptTerms() { 501 var token = getToken(); 502 if (!token) throw new Error('Not logged in'); 503 var res = await fetch(SERVER_URL + '/api/auth/accept-terms', { 504 method: 'POST', 505 headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, 506 body: JSON.stringify({}), 507 }); 508 var data = await res.json().catch(function () { 509 return {}; 510 }); 511 if (!res.ok || !data.ok) { 512 throw new Error((data && data.error) || 'Failed to accept terms'); 513 } 514 if (_cachedUser) _cachedUser.tosVersion = data.tosVersion || _cachedUser.tosVersion; 515 return data.tosVersion || null; 516 } 517 518 /** 519 * Convenience: initialize on page load. Checks for an existing token and 520 * fetches the full profile if one exists. Returns a Promise that resolves 521 * to the user object or null. 522 * 523 * Usage: BFAuth.init().then(user => { if (user) prefillName(user.displayName); }); 524 */ 525 async function init() { 526 var tokenUser = buildUserFromToken(); 527 if (!tokenUser) return null; 528 // Enrich with server data (displayName, avatar, tier) 529 return fetchProfile(); 530 } 531 532 // --------------------------------------------------------------------------- 533 // Export as window.BFAuth 534 // --------------------------------------------------------------------------- 535 536 window.BFAuth = { 537 // Initialization 538 init: init, 539 540 // User state 541 getBFUser: getBFUser, 542 isLoggedIn: isLoggedIn, 543 fetchProfile: fetchProfile, 544 getPlayerId: getPlayerId, 545 getToken: getToken, 546 547 // Authentication actions 548 loginWithBoardForge: loginWithBoardForge, 549 registerWithBoardForge: registerWithBoardForge, 550 logout: logout, 551 552 // Network 553 authFetch: authFetch, 554 555 // Legal / Terms of Service 556 getTosVersion: getTosVersion, 557 getLegalVersion: getLegalVersion, 558 getLegalSummary: getLegalSummary, 559 getLegalDoc: getLegalDoc, 560 acceptTerms: acceptTerms, 561 562 // Utilities 563 getBFAvatarUrl: getBFAvatarUrl, 564 getServerUrl: getServerUrl, 565 566 // Constants (for pages that need to check these directly) 567 TOKEN_KEY: TOKEN_KEY, 568 PLAYER_ID_KEY: PLAYER_ID_KEY, 569 }; 570})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.