PageSourceSearch

https://topdowntabletop.com/m/auth-bridge.js

js topdowntabletop.com collected 2026-10-08 09:15:32 UTC 19,638 bytes, 570 lines download raw bytes

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.